From e135bd1873e2152c2033046e7fe1a885f60be913 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 01:59:47 -0700 Subject: [PATCH 01/19] added sandbox sidebar and updated button links to new widget --- .../thunderscope/common/common_widgets.py | 29 ++++ src/software/thunderscope/gl/BUILD | 5 +- src/software/thunderscope/gl/gl_widget.py | 26 +++- .../gl/layers/gl_sandbox_world_layer.py | 25 ++-- src/software/thunderscope/gl/sandbox/BUILD | 11 ++ .../gl/sandbox/gl_sandbox_sidebar.py | 130 ++++++++++++++++++ src/software/thunderscope/gl/toolbars/BUILD | 36 +++++ .../{widgets => toolbars}/gl_field_toolbar.py | 50 +++---- .../gl_gamecontroller_toolbar.py | 8 +- .../gl/{widgets => toolbars}/gl_toolbar.py | 25 ---- src/software/thunderscope/gl/widgets/BUILD | 33 ----- .../thunderscope/thunderscope_config.py | 5 - .../thunderscope/widget_setup_functions.py | 57 ++++---- 13 files changed, 297 insertions(+), 143 deletions(-) create mode 100644 src/software/thunderscope/gl/sandbox/BUILD create mode 100644 src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py create mode 100644 src/software/thunderscope/gl/toolbars/BUILD rename src/software/thunderscope/gl/{widgets => toolbars}/gl_field_toolbar.py (83%) rename src/software/thunderscope/gl/{widgets => toolbars}/gl_gamecontroller_toolbar.py (97%) rename src/software/thunderscope/gl/{widgets => toolbars}/gl_toolbar.py (52%) diff --git a/src/software/thunderscope/common/common_widgets.py b/src/software/thunderscope/common/common_widgets.py index c7f7fcdb98..6d9d6e122c 100644 --- a/src/software/thunderscope/common/common_widgets.py +++ b/src/software/thunderscope/common/common_widgets.py @@ -197,6 +197,10 @@ def value(self) -> float: """Gets the current value of this progress bar as a float""" return float(super(ColorProgressBar, self).value()) / self.decimals +class StyledButton(QPushButton): + def __init__(self): + self.setStyleSheet(self.get_toggle_button_style()) + class ToggleableButton(QPushButton): """A QPushButton which can be enabled or disabled @@ -498,3 +502,28 @@ def display_tooltip(event, tooltip_text): ) elif str(event.type()) == "Type.Leave": QToolTip.hideText() + +def get_toggle_button_style(is_enabled: bool = True) -> str: + """Returns the stylesheet for a QPushButton based on if it's enabled or not + + :param is_enabled: True if button is enabled, False if not + :return: the corresponding stylesheet indicating the button state + """ + # the style for each toolbar button + return textwrap.dedent( + f""" + QPushButton {{ + color: #969696; + background-color: transparent; + border-color: transparent; + icon-size: 22px; + border-width: 4px; + border-radius: 4px; + height: 16px; + }} + QPushButton:hover {{ + background-color: {"#363636" if is_enabled else "transparent"}; + border-color: {"#363636" if is_enabled else "transparent"}; + }} + """ + ) diff --git a/src/software/thunderscope/gl/BUILD b/src/software/thunderscope/gl/BUILD index 8bfb8a6d48..2a00ea58e7 100644 --- a/src/software/thunderscope/gl/BUILD +++ b/src/software/thunderscope/gl/BUILD @@ -11,8 +11,9 @@ py_library( "//software/thunderscope/gl/helpers:extended_gl_view_widget", "//software/thunderscope/gl/layers:gl_layer", "//software/thunderscope/gl/layers:gl_measure_layer", - "//software/thunderscope/gl/widgets:gl_field_toolbar", - "//software/thunderscope/gl/widgets:gl_gamecontroller_toolbar", + "//software/thunderscope/gl/sandbox:gl_sandbox_sidebar", + "//software/thunderscope/gl/toolbars:gl_field_toolbar", + "//software/thunderscope/gl/toolbars:gl_gamecontroller_toolbar", "//software/thunderscope/replay:replay_controls", requirement("pyqtgraph"), ], diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index 28f64a299e..cb92366099 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -14,11 +14,11 @@ from software.thunderscope.proto_unix_io import ProtoUnixIO from software.thunderscope.gl.layers.gl_layer import GLLayer from software.thunderscope.gl.layers.gl_measure_layer import GLMeasureLayer -from software.thunderscope.gl.widgets.gl_field_toolbar import GLFieldToolbar +from software.thunderscope.gl.toolbars.gl_field_toolbar import GLFieldToolbar from software.thunderscope.replay.proto_player import ProtoPlayer from software.thunderscope.replay.replay_controls import ReplayControls from software.thunderscope.gl.helpers.extended_gl_view_widget import * -from software.thunderscope.gl.widgets.gl_gamecontroller_toolbar import ( +from software.thunderscope.gl.toolbars.gl_gamecontroller_toolbar import ( GLGamecontrollerToolbar, ) from software.thunderscope.thread_safe_buffer import ThreadSafeBuffer @@ -27,6 +27,7 @@ from proto.tbots_timestamp_msg_pb2 import Timestamp from software.thunderscope.common.toast_msg_helper import success_toast +from software.thunderscope.gl.sandbox.gl_sandbox_sidebar import GLSandboxSidebar from typing import override @@ -41,7 +42,6 @@ def __init__( friendly_color_yellow: bool, frame_swap_counter: Optional[FrameTimeCounter] = None, player: Optional[ProtoPlayer] = None, - sandbox_mode: bool = False, ) -> None: """Initialize the GLWidget @@ -49,7 +49,6 @@ def __init__( :param friendly_color_yellow: Whether the friendly team is yellow (true) or blue (false) :param frame_swap_counter: A FrameTimeCounter to track the time between frame swaps :param player: The replay player to optionally display media controls for - :param sandbox_mode: Whether sandbox mode should be enabled """ super().__init__() @@ -88,6 +87,12 @@ def __init__( self.setLayout(self.layout) self.layout.addWidget(self.gl_view_widget) + # Setup sandbox sidebar + self.sandbox_sidebar = GLSandboxSidebar( + parent=self.gl_view_widget, + on_sandbox_mode_toggle=self.on_sandbox_mode_toggle, + ) + # Setup toolbar self.measure_mode_enabled = False self.measure_layer = None @@ -101,9 +106,9 @@ def __init__( on_measure_mode=self.toggle_measure_mode, layers_menu=self.layers_menu, toolbars_menu=self.toolbars_menu, - sandbox_mode=sandbox_mode, replay_mode=player is not None, on_add_bookmark=self.add_bookmark, + on_toggle_sandbox_sidebar=self.toggle_sandbox_sidebar, ) # Setup gamecontroller toolbar @@ -322,6 +327,17 @@ def toggle_measure_mode(self) -> None: else: self.remove_layer(self.measure_layer) + def toggle_sandbox_sidebar(self) -> None: + """Toggles whether the sandbox sidebar is displayed""" + self.sandbox_sidebar.toggle_visibility() + + def on_sandbox_mode_toggle(self, enabled: bool) -> None: + """Callback when sandbox mode is toggled in the sidebar + + :param enabled: whether sandbox mode is enabled + """ + pass + def __add_toolbar_toggle(self, toolbar: QWidget, name: str) -> None: """Adds a button to the toolbar menu to toggle the given toolbar diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 3ad7371ecf..a643172310 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -1,5 +1,6 @@ import math from typing import Optional, override +from dataclasses import dataclass from proto.import_all_protos import * from pyqtgraph.Qt.QtCore import * from pyqtgraph.Qt.QtGui import * @@ -10,24 +11,24 @@ from software.thunderscope.proto_unix_io import ProtoUnixIO +@dataclass class RobotOperation: """An operation that changes the state of the robots on the field Contains the id of the robot to change, its new and previous positions if applicable And the id of the next robot to add after this operation completes """ - def __init__( - self, - id: int, - prev_pos: Optional[tuple[int, int]], - pos: Optional[tuple[int, int]], - next_id: int, - ): - self.type = type - self.id = id - self.prev_pos = prev_pos - self.pos = pos - self.next_id = next_id + id: int + prev_pos: Optional[QVector3D] + pos: Optional[QVector3D] + next_id: int + + +@dataclass +class GroupOperation: + """A group of RobotOperations that should be applied together""" + + operations: list[RobotOperation] class EnemyAtMousePositionError(Exception): diff --git a/src/software/thunderscope/gl/sandbox/BUILD b/src/software/thunderscope/gl/sandbox/BUILD new file mode 100644 index 0000000000..db8026687f --- /dev/null +++ b/src/software/thunderscope/gl/sandbox/BUILD @@ -0,0 +1,11 @@ +load("@thunderscope_deps//:requirements.bzl", "requirement") + +package(default_visibility = ["//visibility:public"]) + +py_library( + name = "gl_sandbox_sidebar", + srcs = ["gl_sandbox_sidebar.py"], + deps = [ + requirement("pyqtgraph"), + ], +) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py new file mode 100644 index 0000000000..26a97e9569 --- /dev/null +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -0,0 +1,130 @@ +from typing import Callable, override +from pyqtgraph.Qt.QtCore import QEvent +from pyqtgraph.Qt.QtWidgets import * + +import qtawesome as qta +from software.thunderscope.common.common_widgets import ToggleableButton + + +class GLSandboxSidebar(QWidget): + """Sidebar widget for the sandbox mode + + Absolutely positioned to the right of its parent, overlaying on top, + taking the full vertical space. Uses a QVBoxLayout to arrange sandbox + controls vertically. + """ + + BUTTON_ICON_COLOR = "white" + SIDEBAR_WIDTH_RATIO = 0.3 + + def __init__( + self, + parent: QWidget, + on_sandbox_mode_toggle: Callable[[bool], None] = None, + ): + """Set up the sandbox sidebar + + :param parent: the parent widget to attach this sidebar to + :param on_sandbox_mode_toggle: callback when sandbox mode is toggled on/off + """ + super().__init__(parent=parent) + + # Setup sidebar with a vertical layout + self.setLayout(QVBoxLayout()) + + self.sidebar_enabled = False + + # Style with a dark background so it's visible when overlaying + self.setStyleSheet( + "background-color: rgba(30, 30, 30, 220);" + "border-left: 1px solid #555;" + ) + + # Setup sandbox mode toggle checkbox + self.sandbox_mode_checkbox = QCheckBox("Enable Sandbox Mode") + self.sandbox_mode_checkbox.setChecked(False) + self.sandbox_mode_checkbox.stateChanged.connect( + lambda checked: on_sandbox_mode_toggle( + self.sandbox_mode_checkbox.isChecked() + ) + if on_sandbox_mode_toggle + else None + ) + self.layout().addWidget(self.sandbox_mode_checkbox) + + # Setup Undo button + self.undo_button = ToggleableButton(False) + self.undo_button.setToolTip("Undo") + self.undo_button.setIcon( + qta.icon("mdi6.undo-variant", color=self.BUTTON_ICON_COLOR) + ) + + # Setup Redo button + self.redo_button = ToggleableButton(False) + self.redo_button.setToolTip("Redo") + self.redo_button.setIcon( + qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + ) + + button_layout = QHBoxLayout() + button_layout.addWidget(self.undo_button) + button_layout.addWidget(self.redo_button) + self.layout().addLayout(button_layout) + + self.layout().addStretch() + + # Setup Clear Field button + self.clear_field = ToggleableButton(False) + self.clear_field.setToolTip("Clear Field") + self.clear_field.setIcon( + qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + ) + + self.layout().addStretch() + + # Setup Create new Test button + self.clear_field = ToggleableButton(False) + self.clear_field.setToolTip("Create new Test") + self.clear_field.setIcon( + qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + ) + + # Setup Add Case to Existing Test button + self.clear_field = ToggleableButton(False) + self.clear_field.setToolTip("Add Case to Existing Test") + self.clear_field.setIcon( + qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + ) + + self.hide() + + # Listen for parent resize events to reposition + parent.installEventFilter(self) + + def toggle_visibility(self): + """Toggle the sidebar visibility""" + self.sidebar_enabled = not self.sidebar_enabled + if self.sidebar_enabled: + self.reposition() + self.show() + else: + self.hide() + + def reposition(self): + """Position to the right edge of the parent, taking full height""" + parent = self.parent() + if parent: + width = int(parent.width() * self.SIDEBAR_WIDTH_RATIO) + self.setGeometry( + parent.width() - width, + 0, + width, + parent.height(), + ) + + @override + def eventFilter(self, obj, event): + """Reposition when the parent is resized""" + if obj is self.parent() and event.type() == QEvent.Resize: + self.reposition() + return super().eventFilter(obj, event) diff --git a/src/software/thunderscope/gl/toolbars/BUILD b/src/software/thunderscope/gl/toolbars/BUILD new file mode 100644 index 0000000000..911d6293ca --- /dev/null +++ b/src/software/thunderscope/gl/toolbars/BUILD @@ -0,0 +1,36 @@ +load("@thunderscope_deps//:requirements.bzl", "requirement") + +package(default_visibility = ["//visibility:public"]) + +py_library( + name = "gl_toolbar", + srcs = ["gl_toolbar.py"], + deps = [ + requirement("pyqtgraph"), + ], +) + +py_library( + name = "gl_field_toolbar", + srcs = ["gl_field_toolbar.py"], + deps = [ + ":gl_toolbar", + "//software/thunderscope/common:common_widgets", + requirement("pyqtgraph"), + requirement("qtawesome"), + ], +) + +py_library( + name = "gl_gamecontroller_toolbar", + srcs = ["gl_gamecontroller_toolbar.py"], + deps = [ + ":gl_toolbar", + "//proto:import_all_protos", + "//software/networking:ssl_proto_communication", + "//software/thunderscope/binary_context_managers:game_controller", + "//software/thunderscope/common:common_widgets", + requirement("pyqtgraph"), + requirement("qtawesome"), + ], +) diff --git a/src/software/thunderscope/gl/widgets/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py similarity index 83% rename from src/software/thunderscope/gl/widgets/gl_field_toolbar.py rename to src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index bac5edd80d..8b065512eb 100644 --- a/src/software/thunderscope/gl/widgets/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -8,8 +8,8 @@ THUNDERSCOPE_HELP_TEXT, SIMULATION_SPEEDS, ) -from software.thunderscope.common.common_widgets import ToggleableButton -from software.thunderscope.gl.widgets.gl_toolbar import GLToolbar +from software.thunderscope.common.common_widgets import ToggleableButton, get_toggle_button_style +from software.thunderscope.gl.toolbars.gl_toolbar import GLToolbar import qtawesome as qta @@ -30,6 +30,7 @@ def __init__( sandbox_mode: bool = False, replay_mode: bool = False, on_add_bookmark=Callable[[], None], + on_toggle_sandbox_sidebar: Callable[[], None], ): """Set up the toolbar with these buttons: @@ -41,22 +42,23 @@ def __init__( - Measure Mode Toggle - Camera View Select menu - Add bookmark + - Toggle sandbox sidebar :param parent: the parent to overlay this toolbar over :param on_camera_view_change: the callback function for when the camera view is changed :param on_measure_mode: the callback function for when measure mode is toggled :param layers_menu: the QMenu for the layers menu selection :param toolbars_menu: the QMenu for the toolbars menu selection - :param sandbox_mode: if sandbox mode should be enabled :param replay_mode: if replay mode is enabled :param on_add_bookmark: the callback function when adding a bookmark + :param on_toggle_sandbox_sidebar: the callback function when toggling the sandbox sidebar """ super(GLFieldToolbar, self).__init__(parent=parent) # Setup Layers button for toggling visibility of layers self.layers_button = QPushButton() self.layers_button.setText("Layers") - self.layers_button.setStyleSheet(self.get_button_style()) + self.layers_button.setStyleSheet(self.get_toggle_button_style()) self.layers_button.setMenu(layers_menu) # Set up View button for setting the camera position to standard views @@ -65,7 +67,7 @@ def __init__( self.camera_view_button.setIcon( qta.icon("msc.device-camera-video", color=self.BUTTON_ICON_COLOR) ) - self.camera_view_button.setStyleSheet(self.get_button_style()) + self.camera_view_button.setStyleSheet(self.get_toggle_button_style()) self.camera_view_menu = QMenu() self.camera_view_button.setMenu(self.camera_view_menu) self.camera_view_actions = [ @@ -95,7 +97,7 @@ def __init__( self.measure_button.setIcon( qta.icon("ph.ruler-light", color=self.BUTTON_ICON_COLOR) ) - self.measure_button.setStyleSheet(self.get_button_style()) + self.measure_button.setStyleSheet(self.get_toggle_button_style()) self.measure_button.setShortcut("m") self.measure_button.clicked.connect(lambda: on_measure_mode()) @@ -105,14 +107,14 @@ def __init__( self.help_button.setIcon( qta.icon("mdi.help-circle", color=self.BUTTON_ICON_COLOR) ) - self.help_button.setStyleSheet(self.get_button_style()) + self.help_button.setStyleSheet(self.get_toggle_button_style()) self.help_button.clicked.connect( lambda: QMessageBox.information(self, "Help", THUNDERSCOPE_HELP_TEXT) ) # Setup pause button self.pause_button = QPushButton() - self.pause_button.setStyleSheet(self.get_button_style()) + self.pause_button.setStyleSheet(self.get_toggle_button_style()) self.toggle_pause_button(True) # buffer for the simulator pause / play state self.simulation_state_buffer = ThreadSafeBuffer(5, SimulationState) @@ -123,7 +125,7 @@ def __init__( self.toolbars_menu = QMenu() self.toolbars_menu_checkboxes = {} self.toolbars_button.setMenu(toolbars_menu) - self.toolbars_button.setStyleSheet(self.get_button_style()) + self.toolbars_button.setStyleSheet(self.get_toggle_button_style()) if not replay_mode: self.bookmark_button = QPushButton() @@ -131,14 +133,14 @@ def __init__( qta.icon(("fa6.bookmark"), color=self.BUTTON_ICON_COLOR) ) self.bookmark_button.setShortcut("b") - self.bookmark_button.setStyleSheet(self.get_button_style()) + self.bookmark_button.setStyleSheet(self.get_toggle_button_style()) self.bookmark_button.clicked.connect(on_add_bookmark) # Setup simulation speed button and menu self.sim_speed_menu = QMenu() self.sim_speed_button = QPushButton() self.sim_speed_button.setText("Speed: 1.00x") - self.sim_speed_button.setStyleSheet(self.get_button_style()) + self.sim_speed_button.setStyleSheet(self.get_toggle_button_style()) self.sim_speed_button.setMenu(self.sim_speed_menu) self.sim_speed_button.setToolTip("Simulation Speed") @@ -152,6 +154,14 @@ def __init__( lambda new_speed=speed: self.speed_callback(new_speed), ) + self.sandbox_sidebar_button = QPushButton() + self.sandbox_sidebar_button.setToolTip("Toggle Sandbox Sidebar") + self.sandbox_sidebar_button.setIcon( + qta.icon("mdi.view-sidebar-outline", color=self.BUTTON_ICON_COLOR) + ) + self.sandbox_sidebar_button.setStyleSheet(self.get_toggle_button_style()) + self.sandbox_sidebar_button.clicked.connect(on_toggle_sandbox_sidebar) + # if sandbox mode, set up the sandbox control buttons if sandbox_mode: # Setup Undo button @@ -160,7 +170,7 @@ def __init__( self.undo_button.setIcon( qta.icon("mdi6.undo-variant", color=self.BUTTON_ICON_COLOR) ) - self.undo_button.setStyleSheet(self.get_button_style(False)) + self.undo_button.setStyleSheet(self.get_toggle_button_style(False)) # Setup Redo button self.redo_button = ToggleableButton(False) @@ -168,7 +178,7 @@ def __init__( self.redo_button.setIcon( qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) ) - self.redo_button.setStyleSheet(self.get_button_style(False)) + self.redo_button.setStyleSheet(self.get_toggle_button_style(False)) self.reset_button = QPushButton() self.reset_button.setToolTip("Reset") @@ -177,22 +187,16 @@ def __init__( "ph.arrow-counter-clockwise-fill", color=self.BUTTON_ICON_COLOR ) ) - self.reset_button.setStyleSheet(self.get_button_style()) + self.reset_button.setStyleSheet(self.get_toggle_button_style()) # Setup toolbar self.layout().addWidget(self.layers_button) self.layout().addWidget(self.toolbars_button) self.layout().addStretch() - if sandbox_mode: - self.layout().addWidget(self.sim_speed_button) - self.layout().addWidget(self.reset_button) - self.layout().addWidget(self.undo_button) - self.layout().addWidget(self.pause_button) - self.layout().addWidget(self.redo_button) - self.layout().addWidget(self.help_button) self.layout().addWidget(self.measure_button) self.layout().addWidget(self.camera_view_button) + self.layout().addWidget(self.sandbox_sidebar_button) if not replay_mode: self.layout().addWidget(self.bookmark_button) @@ -234,7 +238,7 @@ def toggle_undo_enabled(self, enabled: bool) -> None: :param enabled: if the undo button is enabled or not """ self.undo_button.toggle_enabled(enabled) - self.undo_button.setStyleSheet(self.get_button_style(enabled)) + self.undo_button.setStyleSheet(self.get_toggle_button_style(enabled)) self.undo_button.repaint() def toggle_redo_enabled(self, enabled: bool) -> None: @@ -243,7 +247,7 @@ def toggle_redo_enabled(self, enabled: bool) -> None: :param enabled: if the redo button is enabled or not """ self.redo_button.toggle_enabled(enabled) - self.redo_button.setStyleSheet(self.get_button_style(enabled)) + self.redo_button.setStyleSheet(self.get_toggle_button_style(enabled)) self.redo_button.repaint() def set_speed_callback(self, callback: Callable[[float], None]) -> None: diff --git a/src/software/thunderscope/gl/widgets/gl_gamecontroller_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py similarity index 97% rename from src/software/thunderscope/gl/widgets/gl_gamecontroller_toolbar.py rename to src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py index 042b6622e8..9a64484470 100644 --- a/src/software/thunderscope/gl/widgets/gl_gamecontroller_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py @@ -4,7 +4,7 @@ from proto.ssl_gc_common_pb2 import Team as SslTeam from typing import Callable, override import webbrowser -from software.thunderscope.gl.widgets.gl_toolbar import GLToolbar +from software.thunderscope.gl.toolbars.gl_toolbar import GLToolbar from software.thunderscope.proto_unix_io import ProtoUnixIO import qtawesome as qta @@ -80,7 +80,7 @@ def __init__( self.plays_menu_button = QPushButton() self.plays_menu_button.setText("Plays") - self.plays_menu_button.setStyleSheet(self.get_button_style()) + self.plays_menu_button.setStyleSheet(self.get_toggle_button_style()) self.plays_menu_button.setMenu(self.plays_menu) # add play items for each team color @@ -178,7 +178,7 @@ def __toggle_normal_start_button(self) -> None: """Toggles the enabled / disabled state of the Normal Start button""" self.normal_start_enabled = not self.normal_start_enabled self.normal_start_button.setStyleSheet( - self.get_button_style(self.normal_start_enabled) + self.get_toggle_button_style(self.normal_start_enabled) ) self.normal_start_button.setIcon( qta.icon( @@ -207,7 +207,7 @@ def __setup_icon_button( button = QPushButton() button.setIcon(icon) button.setToolTip(tooltip) - button.setStyleSheet(self.get_button_style()) + button.setStyleSheet(self.get_toggle_button_style()) button.clicked.connect(callback) if display_text: diff --git a/src/software/thunderscope/gl/widgets/gl_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_toolbar.py similarity index 52% rename from src/software/thunderscope/gl/widgets/gl_toolbar.py rename to src/software/thunderscope/gl/toolbars/gl_toolbar.py index 06963ae8da..275b3511b0 100644 --- a/src/software/thunderscope/gl/widgets/gl_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_toolbar.py @@ -29,28 +29,3 @@ def __init__(self, parent: QWidget): def refresh(self) -> None: """Refreshes the UI (overridden by child classes)""" raise NotImplementedError("Subclasses must implement this method!") - - def get_button_style(self, is_enabled: bool = True) -> str: - """Returns the stylesheet for a QPushButton based on if it's enabled or not - - :param is_enabled: True if button is enabled, False if not - :return: the corresponding stylesheet indicating the button state - """ - # the style for each toolbar button - return textwrap.dedent( - f""" - QPushButton {{ - color: #969696; - background-color: transparent; - border-color: transparent; - icon-size: 22px; - border-width: 4px; - border-radius: 4px; - height: 16px; - }} - QPushButton:hover {{ - background-color: {"#363636" if is_enabled else "transparent"}; - border-color: {"#363636" if is_enabled else "transparent"}; - }} - """ - ) diff --git a/src/software/thunderscope/gl/widgets/BUILD b/src/software/thunderscope/gl/widgets/BUILD index 7526278ced..bb4dff9f7e 100644 --- a/src/software/thunderscope/gl/widgets/BUILD +++ b/src/software/thunderscope/gl/widgets/BUILD @@ -2,14 +2,6 @@ load("@thunderscope_deps//:requirements.bzl", "requirement") package(default_visibility = ["//visibility:public"]) -py_library( - name = "gl_toolbar", - srcs = ["gl_toolbar.py"], - deps = [ - requirement("pyqtgraph"), - ], -) - py_library( name = "icon_loader", srcs = ["icon_loader.py"], @@ -17,28 +9,3 @@ py_library( requirement("pyqtgraph"), ], ) - -py_library( - name = "gl_field_toolbar", - srcs = ["gl_field_toolbar.py"], - deps = [ - ":gl_toolbar", - "//software/thunderscope/common:common_widgets", - requirement("pyqtgraph"), - requirement("qtawesome"), - ], -) - -py_library( - name = "gl_gamecontroller_toolbar", - srcs = ["gl_gamecontroller_toolbar.py"], - deps = [ - ":gl_toolbar", - "//proto:import_all_protos", - "//software/networking:ssl_proto_communication", - "//software/thunderscope/binary_context_managers:game_controller", - "//software/thunderscope/common:common_widgets", - requirement("pyqtgraph"), - requirement("qtawesome"), - ], -) diff --git a/src/software/thunderscope/thunderscope_config.py b/src/software/thunderscope/thunderscope_config.py index f3c328aee6..98ff0b48b0 100644 --- a/src/software/thunderscope/thunderscope_config.py +++ b/src/software/thunderscope/thunderscope_config.py @@ -106,7 +106,6 @@ def configure_base_fullsystem( full_system_proto_unix_io: ProtoUnixIO, sim_proto_unix_io: ProtoUnixIO, friendly_colour_yellow: bool, - sandbox_mode: bool = False, replay: bool = False, replay_log: os.PathLike = None, visualization_buffer_size: int = 5, @@ -120,7 +119,6 @@ def configure_base_fullsystem( :param full_system_proto_unix_io: the proto unix io to configure widgets with :param sim_proto_unix_io: the proto unix io for the simulator :param friendly_colour_yellow: if this is Yellow FullSystem (True) or Blue (False) - :param sandbox_mode: if sandbox mode should be enabled :param replay: True if in replay mode, False if not :param replay_log: the file path of the replay protos :param visualization_buffer_size: The size of the visualization buffer. @@ -135,7 +133,6 @@ def configure_base_fullsystem( TScopeWidget( name="Field", widget=setup_gl_widget( - sandbox_mode=sandbox_mode, replay=replay, replay_log=replay_log, full_system_proto_unix_io=full_system_proto_unix_io, @@ -290,7 +287,6 @@ def configure_two_ai_gamecontroller_view( sim_proto_unix_io=proto_unix_io_map[ProtoUnixIOTypes.SIM], friendly_colour_yellow=False, visualization_buffer_size=visualization_buffer_size, - sandbox_mode=True, extra_widgets=[], frame_swap_counter=blue_frame_swap_frametime_counter, refresh_counter=blue_refresh_func_frametime_counter, @@ -306,7 +302,6 @@ def configure_two_ai_gamecontroller_view( sim_proto_unix_io=proto_unix_io_map[ProtoUnixIOTypes.SIM], friendly_colour_yellow=True, visualization_buffer_size=visualization_buffer_size, - sandbox_mode=True, extra_widgets=[], frame_swap_counter=yellow_frame_swap_frametime_counter, refresh_counter=yellow_refresh_func_frametime_counter, diff --git a/src/software/thunderscope/widget_setup_functions.py b/src/software/thunderscope/widget_setup_functions.py index 59b4c565ae..8b94873875 100644 --- a/src/software/thunderscope/widget_setup_functions.py +++ b/src/software/thunderscope/widget_setup_functions.py @@ -60,7 +60,6 @@ def setup_gl_widget( full_system_proto_unix_io: ProtoUnixIO, friendly_colour_yellow: bool, visualization_buffer_size: int, - sandbox_mode: bool = False, replay: bool = False, replay_log: os.PathLike = None, frame_swap_counter: Optional[FrameTimeCounter] = None, @@ -72,7 +71,6 @@ def setup_gl_widget( :param full_system_proto_unix_io: The proto unix io object for the full system :param friendly_colour_yellow: Whether the friendly colour is yellow :param visualization_buffer_size: How many packets to buffer while rendering - :param sandbox_mode: if sandbox mode should be enabled :param replay: Whether replay mode is currently enabled :param replay_log: The file path of the replay log :param frame_swap_counter: FrameTimeCounter to keep track of the time between @@ -89,7 +87,6 @@ def setup_gl_widget( friendly_color_yellow=friendly_colour_yellow, frame_swap_counter=frame_swap_counter, player=player, - sandbox_mode=sandbox_mode, ) # Create layers @@ -120,13 +117,6 @@ def setup_gl_widget( friendly_colour_yellow, visualization_buffer_size, ) - if sandbox_mode - else gl_world_layer.GLWorldLayer( - "Vision", - sim_proto_unix_io, - friendly_colour_yellow, - visualization_buffer_size, - ) ) field_movement_layer = gl_movement_field_test_layer.GLMovementFieldTestLayer( "Field Movement Layer", full_system_proto_unix_io @@ -167,30 +157,29 @@ def setup_gl_widget( simulation_control_toolbar.set_speed_callback(world_layer.set_simulation_speed) # connect all sandbox controls if using sandbox mode - if sandbox_mode: - simulation_control_toolbar.undo_button.clicked.connect(world_layer.undo) - simulation_control_toolbar.redo_button.clicked.connect(world_layer.redo) - simulation_control_toolbar.reset_button.clicked.connect( - world_layer.reset_to_pre_sim - ) - world_layer.undo_toggle_enabled_signal.connect( - simulation_control_toolbar.toggle_undo_enabled - ) - world_layer.redo_toggle_enabled_signal.connect( - simulation_control_toolbar.toggle_redo_enabled - ) - simulation_control_toolbar.pause_button.clicked.connect( - world_layer.toggle_play_state - ) - sim_proto_unix_io.register_observer( - SimulationState, simulation_control_toolbar.simulation_state_buffer - ) - sim_proto_unix_io.register_observer( - SimulationState, world_layer.simulation_state_buffer - ) - sim_proto_unix_io.register_observer( - SimulationState, gl_widget.simulation_state_buffer - ) + simulation_control_toolbar.undo_button.clicked.connect(world_layer.undo) + simulation_control_toolbar.redo_button.clicked.connect(world_layer.redo) + simulation_control_toolbar.reset_button.clicked.connect( + world_layer.reset_to_pre_sim + ) + world_layer.undo_toggle_enabled_signal.connect( + simulation_control_toolbar.toggle_undo_enabled + ) + world_layer.redo_toggle_enabled_signal.connect( + simulation_control_toolbar.toggle_redo_enabled + ) + simulation_control_toolbar.pause_button.clicked.connect( + world_layer.toggle_play_state + ) + sim_proto_unix_io.register_observer( + SimulationState, simulation_control_toolbar.simulation_state_buffer + ) + sim_proto_unix_io.register_observer( + SimulationState, world_layer.simulation_state_buffer + ) + sim_proto_unix_io.register_observer( + SimulationState, gl_widget.simulation_state_buffer + ) for arg in [ (World, world_layer.world_buffer), From 65cf19ff24837cde8797d19e29d72b6d5a897e3a Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 02:22:54 -0700 Subject: [PATCH 02/19] add buttons in preparation of test export --- .../thunderscope/common/common_widgets.py | 19 ++- .../gl/layers/gl_sandbox_world_layer.py | 110 ++++++++++++++---- .../gl/sandbox/gl_sandbox_sidebar.py | 33 +++--- .../gl/toolbars/gl_field_toolbar.py | 39 +++---- 4 files changed, 136 insertions(+), 65 deletions(-) diff --git a/src/software/thunderscope/common/common_widgets.py b/src/software/thunderscope/common/common_widgets.py index 6d9d6e122c..c13642c602 100644 --- a/src/software/thunderscope/common/common_widgets.py +++ b/src/software/thunderscope/common/common_widgets.py @@ -198,13 +198,23 @@ def value(self) -> float: return float(super(ColorProgressBar, self).value()) / self.decimals class StyledButton(QPushButton): - def __init__(self): - self.setStyleSheet(self.get_toggle_button_style()) + """A QPushButton with the toolbar button stylesheet pre-applied. + + This button automatically applies the toggle button style used by toolbar + buttons, so callers don't need to manually call setStyleSheet with + get_toggle_button_style(). StyledButton defaults to the enabled state, with + hover highlighting applied. + """ + + def __init__(self, parent: QWidget = None): + super().__init__(parent) + self.setStyleSheet(get_toggle_button_style()) class ToggleableButton(QPushButton): """A QPushButton which can be enabled or disabled Indicates with cursor if it is enabled or disabled + Auto-updates the toolbar button stylesheet based on enabled state """ def __init__(self, enabled: bool): @@ -214,12 +224,15 @@ def __init__(self, enabled: bool): """ super(ToggleableButton, self).__init__() self.enabled = enabled + self.setStyleSheet(get_toggle_button_style(enabled)) def toggle_enabled(self, enabled: bool): - """Toggles the enabled state of the button + """Toggles the enabled state of the button and updates the stylesheet + :param enabled: the new enabled state """ self.enabled = enabled + self.setStyleSheet(get_toggle_button_style(enabled)) @override def enterEvent(self, event) -> None: diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index a643172310..9d65d268ef 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -230,23 +230,39 @@ def undo(self) -> None: # get the operation which undoes the previous one operation = self.undo_operations.pop() - # add an opposite operation to the redo list with pos and prev_pos swapped - self.redo_operations.append( - RobotOperation( - id=operation.id, - prev_pos=operation.pos, - pos=operation.prev_pos, - next_id=self.next_id, + if isinstance(operation, GroupOperation): + # For a GroupOperation, create a reverse GroupOperation for redo + redo_ops = [] + for op in operation.operations: + redo_ops.append( + RobotOperation( + id=op.id, + prev_pos=op.pos, + pos=op.prev_pos, + next_id=self.next_id, + ) + ) + self.redo_operations.append(GroupOperation(operations=redo_ops)) + + # Apply each operation (restore each robot) + for op in operation.operations: + self.__undo_redo_internal(op) + else: + # add an opposite operation to the redo list with pos and prev_pos swapped + self.redo_operations.append( + RobotOperation( + id=operation.id, + prev_pos=operation.pos, + pos=operation.prev_pos, + next_id=self.next_id, + ) ) - ) + self.__undo_redo_internal(operation) # enable / disable the undo and redo buttons self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) - # apply the operation - self.__undo_redo_internal(operation) - def redo(self) -> None: """Redoes the last undo operation Adds a corresponding opposite move to the undo list so we can undo if necessary @@ -258,22 +274,74 @@ def redo(self) -> None: # get the operation operation = self.redo_operations.pop() - # add an opposite operation to the undo list with pos and prev_pos swapped - self.undo_operations.append( - RobotOperation( - id=operation.id, - prev_pos=operation.pos, - pos=operation.prev_pos, - next_id=self.next_id, + if isinstance(operation, GroupOperation): + # For a GroupOperation, create a reverse GroupOperation for undo + undo_ops = [] + for op in operation.operations: + undo_ops.append( + RobotOperation( + id=op.id, + prev_pos=op.pos, + pos=op.prev_pos, + next_id=self.next_id, + ) + ) + self.undo_operations.append(GroupOperation(operations=undo_ops)) + + # Apply each operation + for op in operation.operations: + self.__undo_redo_internal(op) + else: + # add an opposite operation to the undo list with pos and prev_pos swapped + self.undo_operations.append( + RobotOperation( + id=operation.id, + prev_pos=operation.pos, + pos=operation.prev_pos, + next_id=self.next_id, + ) ) - ) + self.__undo_redo_internal(operation) # enable / disable the undo and redo buttons self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) - # apply the operation - self.__undo_redo_internal(operation) + def clear_field(self) -> None: + """Removes all robots from the field. + Adds a GroupOperation to the undo list that can restore all robots. + """ + # collect current robot positions into a GroupOperation for undo + operations = [] + for robot_id in list(self.curr_robot_ids): + # get the current position from pre_sim_robot_positions + pos_data = self.pre_sim_robot_positions.get(robot_id) + if pos_data is not None: + pos, _ = pos_data + operations.append( + RobotOperation( + id=robot_id, + prev_pos=None, # robot will be removed + pos=pos, # restore to this position on undo + next_id=self.next_id, + ) + ) + + if operations: + self.undo_operations.append(GroupOperation(operations=operations)) + self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) + + # clear internal state + self.curr_robot_ids.clear() + self.pre_sim_robot_positions.clear() + self.local_robot_positions.clear() + + # clear redo list since this is a new action + self.redo_operations.clear() + self.redo_toggle_enabled_signal.emit(False) + + # send empty world state + self.simulator_io.send_proto(WorldState, WorldState()) @override def toggle_play_state(self) -> bool: diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 26a97e9569..61612e2877 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -71,30 +71,31 @@ def __init__( button_layout.addWidget(self.redo_button) self.layout().addLayout(button_layout) - self.layout().addStretch() - # Setup Clear Field button - self.clear_field = ToggleableButton(False) - self.clear_field.setToolTip("Clear Field") - self.clear_field.setIcon( - qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + self.clear_field_button = ToggleableButton(False) + self.clear_field_button.setToolTip("Clear Field") + self.clear_field_button.setIcon( + qta.icon("mdi6.delete-variant", color=self.BUTTON_ICON_COLOR) ) - - self.layout().addStretch() + self.layout().addWidget(self.clear_field_button) # Setup Create new Test button - self.clear_field = ToggleableButton(False) - self.clear_field.setToolTip("Create new Test") - self.clear_field.setIcon( - qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + self.create_test_button = ToggleableButton(False) + self.create_test_button.setToolTip("Create new Test") + self.create_test_button.setIcon( + qta.icon("mdi6.flask-outline", color=self.BUTTON_ICON_COLOR) ) + self.layout().addWidget(self.create_test_button) # Setup Add Case to Existing Test button - self.clear_field = ToggleableButton(False) - self.clear_field.setToolTip("Add Case to Existing Test") - self.clear_field.setIcon( - qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + self.add_case_button = ToggleableButton(False) + self.add_case_button.setToolTip("Add Case to Existing Test") + self.add_case_button.setIcon( + qta.icon("mdi6.test-tube", color=self.BUTTON_ICON_COLOR) ) + self.layout().addWidget(self.add_case_button) + + self.layout().addStretch() self.hide() diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index 8b065512eb..7e11f6b12c 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -8,7 +8,10 @@ THUNDERSCOPE_HELP_TEXT, SIMULATION_SPEEDS, ) -from software.thunderscope.common.common_widgets import ToggleableButton, get_toggle_button_style +from software.thunderscope.common.common_widgets import ( + ToggleableButton, + StyledButton, +) from software.thunderscope.gl.toolbars.gl_toolbar import GLToolbar import qtawesome as qta @@ -56,18 +59,16 @@ def __init__( super(GLFieldToolbar, self).__init__(parent=parent) # Setup Layers button for toggling visibility of layers - self.layers_button = QPushButton() + self.layers_button = StyledButton() self.layers_button.setText("Layers") - self.layers_button.setStyleSheet(self.get_toggle_button_style()) self.layers_button.setMenu(layers_menu) # Set up View button for setting the camera position to standard views - self.camera_view_button = QPushButton() + self.camera_view_button = StyledButton() self.camera_view_button.setToolTip("View") self.camera_view_button.setIcon( qta.icon("msc.device-camera-video", color=self.BUTTON_ICON_COLOR) ) - self.camera_view_button.setStyleSheet(self.get_toggle_button_style()) self.camera_view_menu = QMenu() self.camera_view_button.setMenu(self.camera_view_menu) self.camera_view_actions = [ @@ -92,55 +93,49 @@ def __init__( self.camera_view_menu.addAction(camera_view_action) # Setup Measure button for enabling/disabling measure mode - self.measure_button = QPushButton() + self.measure_button = StyledButton() self.measure_button.setToolTip("Measure") self.measure_button.setIcon( qta.icon("ph.ruler-light", color=self.BUTTON_ICON_COLOR) ) - self.measure_button.setStyleSheet(self.get_toggle_button_style()) self.measure_button.setShortcut("m") self.measure_button.clicked.connect(lambda: on_measure_mode()) # Setup Help button - self.help_button = QPushButton() + self.help_button = StyledButton() self.help_button.setToolTip("Help") self.help_button.setIcon( qta.icon("mdi.help-circle", color=self.BUTTON_ICON_COLOR) ) - self.help_button.setStyleSheet(self.get_toggle_button_style()) self.help_button.clicked.connect( lambda: QMessageBox.information(self, "Help", THUNDERSCOPE_HELP_TEXT) ) # Setup pause button - self.pause_button = QPushButton() - self.pause_button.setStyleSheet(self.get_toggle_button_style()) + self.pause_button = StyledButton() self.toggle_pause_button(True) # buffer for the simulator pause / play state self.simulation_state_buffer = ThreadSafeBuffer(5, SimulationState) # Setup Toolbars button for toggling visibility of toolbars - self.toolbars_button = QPushButton() + self.toolbars_button = StyledButton() self.toolbars_button.setText("Toolbars") self.toolbars_menu = QMenu() self.toolbars_menu_checkboxes = {} self.toolbars_button.setMenu(toolbars_menu) - self.toolbars_button.setStyleSheet(self.get_toggle_button_style()) if not replay_mode: - self.bookmark_button = QPushButton() + self.bookmark_button = StyledButton() self.bookmark_button.setIcon( qta.icon(("fa6.bookmark"), color=self.BUTTON_ICON_COLOR) ) self.bookmark_button.setShortcut("b") - self.bookmark_button.setStyleSheet(self.get_toggle_button_style()) self.bookmark_button.clicked.connect(on_add_bookmark) # Setup simulation speed button and menu self.sim_speed_menu = QMenu() - self.sim_speed_button = QPushButton() + self.sim_speed_button = StyledButton() self.sim_speed_button.setText("Speed: 1.00x") - self.sim_speed_button.setStyleSheet(self.get_toggle_button_style()) self.sim_speed_button.setMenu(self.sim_speed_menu) self.sim_speed_button.setToolTip("Simulation Speed") @@ -154,12 +149,11 @@ def __init__( lambda new_speed=speed: self.speed_callback(new_speed), ) - self.sandbox_sidebar_button = QPushButton() + self.sandbox_sidebar_button = StyledButton() self.sandbox_sidebar_button.setToolTip("Toggle Sandbox Sidebar") self.sandbox_sidebar_button.setIcon( qta.icon("mdi.view-sidebar-outline", color=self.BUTTON_ICON_COLOR) ) - self.sandbox_sidebar_button.setStyleSheet(self.get_toggle_button_style()) self.sandbox_sidebar_button.clicked.connect(on_toggle_sandbox_sidebar) # if sandbox mode, set up the sandbox control buttons @@ -170,7 +164,6 @@ def __init__( self.undo_button.setIcon( qta.icon("mdi6.undo-variant", color=self.BUTTON_ICON_COLOR) ) - self.undo_button.setStyleSheet(self.get_toggle_button_style(False)) # Setup Redo button self.redo_button = ToggleableButton(False) @@ -178,16 +171,14 @@ def __init__( self.redo_button.setIcon( qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) ) - self.redo_button.setStyleSheet(self.get_toggle_button_style(False)) - self.reset_button = QPushButton() + self.reset_button = StyledButton() self.reset_button.setToolTip("Reset") self.reset_button.setIcon( qta.icon( "ph.arrow-counter-clockwise-fill", color=self.BUTTON_ICON_COLOR ) ) - self.reset_button.setStyleSheet(self.get_toggle_button_style()) # Setup toolbar self.layout().addWidget(self.layers_button) @@ -238,7 +229,6 @@ def toggle_undo_enabled(self, enabled: bool) -> None: :param enabled: if the undo button is enabled or not """ self.undo_button.toggle_enabled(enabled) - self.undo_button.setStyleSheet(self.get_toggle_button_style(enabled)) self.undo_button.repaint() def toggle_redo_enabled(self, enabled: bool) -> None: @@ -247,7 +237,6 @@ def toggle_redo_enabled(self, enabled: bool) -> None: :param enabled: if the redo button is enabled or not """ self.redo_button.toggle_enabled(enabled) - self.redo_button.setStyleSheet(self.get_toggle_button_style(enabled)) self.redo_button.repaint() def set_speed_callback(self, callback: Callable[[float], None]) -> None: From 57e317852b29efbd883d22a78e6a53d8339873c7 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 05:04:16 -0700 Subject: [PATCH 03/19] finished sidebar skeleton --- .../thunderscope/gl/sandbox/gl_sandbox_sidebar.py | 1 - .../gl/toolbars/gl_gamecontroller_toolbar.py | 15 +++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 61612e2877..8857a2eb1b 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -5,7 +5,6 @@ import qtawesome as qta from software.thunderscope.common.common_widgets import ToggleableButton - class GLSandboxSidebar(QWidget): """Sidebar widget for the sandbox mode diff --git a/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py index 9a64484470..010dc24420 100644 --- a/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py @@ -6,6 +6,7 @@ import webbrowser from software.thunderscope.gl.toolbars.gl_toolbar import GLToolbar from software.thunderscope.proto_unix_io import ProtoUnixIO +from software.thunderscope.common.common_widgets import ToggleableButton import qtawesome as qta @@ -78,9 +79,8 @@ def __init__( # set up the menu for selecting plays self.plays_menu = QMenu() - self.plays_menu_button = QPushButton() + self.plays_menu_button = StyledButton() self.plays_menu_button.setText("Plays") - self.plays_menu_button.setStyleSheet(self.get_toggle_button_style()) self.plays_menu_button.setMenu(self.plays_menu) # add play items for each team color @@ -177,9 +177,7 @@ def __plays_menu_handler( def __toggle_normal_start_button(self) -> None: """Toggles the enabled / disabled state of the Normal Start button""" self.normal_start_enabled = not self.normal_start_enabled - self.normal_start_button.setStyleSheet( - self.get_toggle_button_style(self.normal_start_enabled) - ) + self.normal_start_button.toggle_enabled(self.normal_start_enabled) self.normal_start_button.setIcon( qta.icon( "fa5s.play", @@ -195,7 +193,7 @@ def __setup_icon_button( tooltip: str, callback: Callable[[], None], display_text: str = None, - ) -> QPushButton: + ) -> StyledButton: """Sets up a button with the given name and callback :param icon: the icon displayed on the button @@ -204,14 +202,15 @@ def __setup_icon_button( :param display_text: optional param if button needs both text and an icon :return: the button """ - button = QPushButton() + button = ToggleableButton() button.setIcon(icon) button.setToolTip(tooltip) - button.setStyleSheet(self.get_toggle_button_style()) button.clicked.connect(callback) if display_text: button.setText(display_text) + + button.toggle_enabled(True) return button def __send_stop_command(self) -> None: From 94c2bd09150284a9021d9a16790f9f2aeab42a48 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 05:45:52 -0700 Subject: [PATCH 04/19] fix clear logic + include local state --- src/software/thunderscope/gl/gl_widget.py | 21 ++++---- .../gl/layers/gl_sandbox_world_layer.py | 48 +++++++++++++------ .../gl/sandbox/gl_sandbox_sidebar.py | 6 +-- .../gl/toolbars/gl_field_toolbar.py | 37 ++++---------- 4 files changed, 57 insertions(+), 55 deletions(-) diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index d2ba8ba181..bc941f650f 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -88,9 +88,12 @@ def __init__( self.layout.addWidget(self.gl_view_widget) # Setup sandbox sidebar + self.sandbox_sidebar_visible = False + self.sandbox_mode_enabled = False + self.sandbox_mode_callback = None self.sandbox_sidebar = GLSandboxSidebar( parent=self.gl_view_widget, - on_sandbox_mode_toggle=self.on_sandbox_mode_toggle, + on_sandbox_mode_toggle=self.toggle_sandbox_mode ) # Setup toolbar @@ -108,7 +111,7 @@ def __init__( toolbars_menu=self.toolbars_menu, replay_mode=player is not None, on_add_bookmark=self.add_bookmark, - on_toggle_sandbox_sidebar=self.toggle_sandbox_sidebar, + on_toggle_sidebar=self.toggle_sidebar_visibility ) # Setup gamecontroller toolbar @@ -335,16 +338,16 @@ def toggle_measure_mode(self) -> None: else: self.remove_layer(self.measure_layer) - def toggle_sandbox_sidebar(self) -> None: + def toggle_sidebar_visibility(self) -> None: """Toggles whether the sandbox sidebar is displayed""" - self.sandbox_sidebar.toggle_visibility() + self.sidebar_enabled = not self.sidebar_enabled + self.sandbox_sidebar.toggle_visibility(self.sidebar_enabled) - def on_sandbox_mode_toggle(self, enabled: bool) -> None: - """Callback when sandbox mode is toggled in the sidebar + def toggle_sandbox_mode(self) -> None: + self.sandbox_mode_enabled = not self.sandbox_mode_enabled - :param enabled: whether sandbox mode is enabled - """ - pass + if self.sandbox_mode_callback: + self.sandbox_mode_callback(self.sandbox_mode_enabled) def __add_toolbar_toggle(self, toolbar: QWidget, name: str) -> None: """Adds a button to the toolbar menu to toggle the given toolbar diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 9d65d268ef..dcae5f4780 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -308,24 +308,42 @@ def redo(self) -> None: self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) def clear_field(self) -> None: - """Removes all robots from the field. + """ + Removes all robots from the field. Adds a GroupOperation to the undo list that can restore all robots. """ # collect current robot positions into a GroupOperation for undo - operations = [] - for robot_id in list(self.curr_robot_ids): - # get the current position from pre_sim_robot_positions - pos_data = self.pre_sim_robot_positions.get(robot_id) - if pos_data is not None: - pos, _ = pos_data - operations.append( - RobotOperation( - id=robot_id, - prev_pos=None, # robot will be removed - pos=pos, # restore to this position on undo - next_id=self.next_id, - ) - ) + operations = {} + + # check the cached world state first + for robot_ in self.cached_world.friendly_team.team_robots: + # get the coordinates from the robot state + pos_x = robot_.current_state.global_position.x_meters + pos_y = robot_.current_state.global_position.y_meters + + operations[robot_.id] = RobotOperation( + id=robot_.id, + prev_pos=None, + pos=QVector3D( + robot.current_state.global_position.x_meters, + robot.current_state.global_position.y_meters, + 0, + ), + next_id=self.next_id, + ) + + # then, update with local state + for robot_id, pos in self.local_robot_positions.items(): + # if the local robot has already been removed, skip it + if pos is None: + continue + + operations[robot_id] = RobotOperation( + id=robot_id, + prev_pos=None, + pos=pos, + next_id=self.next_id, + ) if operations: self.undo_operations.append(GroupOperation(operations=operations)) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 8857a2eb1b..a3eecd0132 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -46,8 +46,6 @@ def __init__( lambda checked: on_sandbox_mode_toggle( self.sandbox_mode_checkbox.isChecked() ) - if on_sandbox_mode_toggle - else None ) self.layout().addWidget(self.sandbox_mode_checkbox) @@ -101,9 +99,9 @@ def __init__( # Listen for parent resize events to reposition parent.installEventFilter(self) - def toggle_visibility(self): + def toggle_visibility(self, enabled: bool): """Toggle the sidebar visibility""" - self.sidebar_enabled = not self.sidebar_enabled + self.sidebar_enabled = enabled if self.sidebar_enabled: self.reposition() self.show() diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index cb80873ca8..94222eec3f 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -33,7 +33,7 @@ def __init__( sandbox_mode: bool = False, replay_mode: bool = False, on_add_bookmark=Callable[[], None], - on_toggle_sandbox_sidebar: Callable[[], None], + on_toggle_sidebar: Callable[[], None], ): """Set up the toolbar with these buttons: @@ -54,7 +54,7 @@ def __init__( :param toolbars_menu: the QMenu for the toolbars menu selection :param replay_mode: if replay mode is enabled :param on_add_bookmark: the callback function when adding a bookmark - :param on_toggle_sandbox_sidebar: the callback function when toggling the sandbox sidebar + :param on_toggle_sidebar: the callback function when toggling the sandbox sidebar """ super(GLFieldToolbar, self).__init__(parent=parent) @@ -156,31 +156,7 @@ def __init__( self.sandbox_sidebar_button.setIcon( qta.icon("mdi.view-sidebar-outline", color=self.BUTTON_ICON_COLOR) ) - self.sandbox_sidebar_button.clicked.connect(on_toggle_sandbox_sidebar) - - # if sandbox mode, set up the sandbox control buttons - if sandbox_mode: - # Setup Undo button - self.undo_button = ToggleableButton(False) - self.undo_button.setToolTip("Undo") - self.undo_button.setIcon( - qta.icon("mdi6.undo-variant", color=self.BUTTON_ICON_COLOR) - ) - - # Setup Redo button - self.redo_button = ToggleableButton(False) - self.redo_button.setToolTip("Redo") - self.redo_button.setIcon( - qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) - ) - - self.reset_button = StyledButton() - self.reset_button.setToolTip("Reset") - self.reset_button.setIcon( - qta.icon( - "ph.arrow-counter-clockwise-fill", color=self.BUTTON_ICON_COLOR - ) - ) + self.sandbox_sidebar_button.clicked.connect(self.on_toggle_sidebar) # Setup toolbar self.layout().addWidget(self.layers_button) @@ -247,3 +223,10 @@ def set_speed_callback(self, callback: Callable[[float], None]) -> None: :param callback: the callback function to update the simulation speed """ self.speed_callback = callback + + def set_sandbox_toggle_callback(self, callback: Callable[[bool], None]) -> None: + """Sets the callback function for toggling sandbox mode + + :param callback: the callback function to toggle sandbox mode + """ + self.sandbox_toggle_callback = callback From 9d90557fd1627c6fc4891816ec828eb87caba2a3 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 05:54:51 -0700 Subject: [PATCH 05/19] changed undo redo logic for group ops --- .../gl/layers/gl_sandbox_world_layer.py | 95 +++++++++---------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index dcae5f4780..8b095220ba 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -1,4 +1,5 @@ import math +from abc import ABC, abstractmethod from typing import Optional, override from dataclasses import dataclass from proto.import_all_protos import * @@ -11,8 +12,21 @@ from software.thunderscope.proto_unix_io import ProtoUnixIO +class Operation(ABC): + """Interface for operations that can be undone/redone""" + + @abstractmethod + def reverse(self, next_id: int) -> "Operation": + """Returns the reverse of this operation + + :param next_id: the next robot id to use in the reversed operation + :return: the reverse of this operation + """ + pass + + @dataclass -class RobotOperation: +class RobotOperation(Operation): """An operation that changes the state of the robots on the field Contains the id of the robot to change, its new and previous positions if applicable And the id of the next robot to add after this operation completes @@ -23,13 +37,36 @@ class RobotOperation: pos: Optional[QVector3D] next_id: int + @override + def reverse(self, next_id: int) -> Operation: + return RobotOperation( + id=self.id, + prev_pos=self.pos, + pos=self.prev_pos, + next_id=next_id, + ) + @dataclass -class GroupOperation: +class GroupOperation(Operation): """A group of RobotOperations that should be applied together""" operations: list[RobotOperation] + @override + def reverse(self, next_id: int) -> Operation: + return GroupOperation( + operations=[ + RobotOperation( + id=op.id, + prev_pos=op.pos, + pos=op.prev_pos, + next_id=next_id, + ) + for op in self.operations + ] + ) + class EnemyAtMousePositionError(Exception): pass @@ -230,33 +267,14 @@ def undo(self) -> None: # get the operation which undoes the previous one operation = self.undo_operations.pop() - if isinstance(operation, GroupOperation): - # For a GroupOperation, create a reverse GroupOperation for redo - redo_ops = [] - for op in operation.operations: - redo_ops.append( - RobotOperation( - id=op.id, - prev_pos=op.pos, - pos=op.prev_pos, - next_id=self.next_id, - ) - ) - self.redo_operations.append(GroupOperation(operations=redo_ops)) + # add the reverse operation to the redo list + self.redo_operations.append(operation.reverse(self.next_id)) - # Apply each operation (restore each robot) + # apply the operation + if isinstance(operation, GroupOperation): for op in operation.operations: self.__undo_redo_internal(op) else: - # add an opposite operation to the redo list with pos and prev_pos swapped - self.redo_operations.append( - RobotOperation( - id=operation.id, - prev_pos=operation.pos, - pos=operation.prev_pos, - next_id=self.next_id, - ) - ) self.__undo_redo_internal(operation) # enable / disable the undo and redo buttons @@ -274,33 +292,14 @@ def redo(self) -> None: # get the operation operation = self.redo_operations.pop() - if isinstance(operation, GroupOperation): - # For a GroupOperation, create a reverse GroupOperation for undo - undo_ops = [] - for op in operation.operations: - undo_ops.append( - RobotOperation( - id=op.id, - prev_pos=op.pos, - pos=op.prev_pos, - next_id=self.next_id, - ) - ) - self.undo_operations.append(GroupOperation(operations=undo_ops)) + # add the reverse operation to the undo list + self.undo_operations.append(operation.reverse(self.next_id)) - # Apply each operation + # apply the operation + if isinstance(operation, GroupOperation): for op in operation.operations: self.__undo_redo_internal(op) else: - # add an opposite operation to the undo list with pos and prev_pos swapped - self.undo_operations.append( - RobotOperation( - id=operation.id, - prev_pos=operation.pos, - pos=operation.prev_pos, - next_id=self.next_id, - ) - ) self.__undo_redo_internal(operation) # enable / disable the undo and redo buttons From a80941655b01edcd8a1cbaec7634f0bfbd56e622 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 06:40:42 -0700 Subject: [PATCH 06/19] format --- src/software/thunderscope/common/common_widgets.py | 2 ++ src/software/thunderscope/gl/gl_widget.py | 5 ++--- .../gl/layers/gl_sandbox_world_layer.py | 3 +-- .../thunderscope/gl/sandbox/gl_sandbox_sidebar.py | 4 ++-- src/software/thunderscope/gl/toolbars/gl_toolbar.py | 1 - src/software/thunderscope/widget_setup_functions.py | 13 +++++-------- 6 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/software/thunderscope/common/common_widgets.py b/src/software/thunderscope/common/common_widgets.py index c13642c602..e21d2cee07 100644 --- a/src/software/thunderscope/common/common_widgets.py +++ b/src/software/thunderscope/common/common_widgets.py @@ -197,6 +197,7 @@ def value(self) -> float: """Gets the current value of this progress bar as a float""" return float(super(ColorProgressBar, self).value()) / self.decimals + class StyledButton(QPushButton): """A QPushButton with the toolbar button stylesheet pre-applied. @@ -516,6 +517,7 @@ def display_tooltip(event, tooltip_text): elif str(event.type()) == "Type.Leave": QToolTip.hideText() + def get_toggle_button_style(is_enabled: bool = True) -> str: """Returns the stylesheet for a QPushButton based on if it's enabled or not diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index bc941f650f..a4a1194925 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -92,8 +92,7 @@ def __init__( self.sandbox_mode_enabled = False self.sandbox_mode_callback = None self.sandbox_sidebar = GLSandboxSidebar( - parent=self.gl_view_widget, - on_sandbox_mode_toggle=self.toggle_sandbox_mode + parent=self.gl_view_widget, on_sandbox_mode_toggle=self.toggle_sandbox_mode ) # Setup toolbar @@ -111,7 +110,7 @@ def __init__( toolbars_menu=self.toolbars_menu, replay_mode=player is not None, on_add_bookmark=self.add_bookmark, - on_toggle_sidebar=self.toggle_sidebar_visibility + on_toggle_sidebar=self.toggle_sidebar_visibility, ) # Setup gamecontroller toolbar diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 8b095220ba..907834a772 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -307,8 +307,7 @@ def redo(self) -> None: self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) def clear_field(self) -> None: - """ - Removes all robots from the field. + """Removes all robots from the field. Adds a GroupOperation to the undo list that can restore all robots. """ # collect current robot positions into a GroupOperation for undo diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index a3eecd0132..8ba45fdadd 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -5,6 +5,7 @@ import qtawesome as qta from software.thunderscope.common.common_widgets import ToggleableButton + class GLSandboxSidebar(QWidget): """Sidebar widget for the sandbox mode @@ -35,8 +36,7 @@ def __init__( # Style with a dark background so it's visible when overlaying self.setStyleSheet( - "background-color: rgba(30, 30, 30, 220);" - "border-left: 1px solid #555;" + "background-color: rgba(30, 30, 30, 220);" "border-left: 1px solid #555;" ) # Setup sandbox mode toggle checkbox diff --git a/src/software/thunderscope/gl/toolbars/gl_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_toolbar.py index 275b3511b0..c3fa2970cd 100644 --- a/src/software/thunderscope/gl/toolbars/gl_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_toolbar.py @@ -1,4 +1,3 @@ -import textwrap from pyqtgraph.Qt import QtCore from pyqtgraph.Qt.QtWidgets import * diff --git a/src/software/thunderscope/widget_setup_functions.py b/src/software/thunderscope/widget_setup_functions.py index 8b94873875..d5c85f5d2b 100644 --- a/src/software/thunderscope/widget_setup_functions.py +++ b/src/software/thunderscope/widget_setup_functions.py @@ -25,7 +25,6 @@ gl_passing_layer, gl_attacker_layer, gl_sandbox_world_layer, - gl_world_layer, gl_debug_shapes_layer, gl_simulator_layer, gl_tactic_layer, @@ -110,13 +109,11 @@ def setup_gl_widget( cost_vis_layer = gl_cost_vis_layer.GLCostVisLayer( "Passing Cost", visualization_buffer_size ) - world_layer = ( - gl_sandbox_world_layer.GLSandboxWorldLayer( - "Vision", - sim_proto_unix_io, - friendly_colour_yellow, - visualization_buffer_size, - ) + world_layer = gl_sandbox_world_layer.GLSandboxWorldLayer( + "Vision", + sim_proto_unix_io, + friendly_colour_yellow, + visualization_buffer_size, ) field_movement_layer = gl_movement_field_test_layer.GLMovementFieldTestLayer( "Field Movement Layer", full_system_proto_unix_io From e353d9cb63ae915c4a56fc4d47b5d1109736047d Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 06:44:29 -0700 Subject: [PATCH 07/19] format --- src/software/thunderscope/gl/toolbars/gl_field_toolbar.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index 94222eec3f..aec1a232d7 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -9,7 +9,6 @@ SIMULATION_SPEEDS, ) from software.thunderscope.common.common_widgets import ( - ToggleableButton, StyledButton, ) from software.thunderscope.gl.toolbars.gl_toolbar import GLToolbar @@ -28,12 +27,12 @@ def __init__( parent: QWidget, on_camera_view_change: Callable[[CameraView], None], on_measure_mode: Callable[[], None], + on_toggle_sidebar: Callable[[], None], layers_menu: QMenu, toolbars_menu: QMenu, sandbox_mode: bool = False, replay_mode: bool = False, on_add_bookmark=Callable[[], None], - on_toggle_sidebar: Callable[[], None], ): """Set up the toolbar with these buttons: From 832be4b9cc11d361b7da45a104aa290eeaf5cfcc Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 21:21:48 -0700 Subject: [PATCH 08/19] fix bugs with the UI + fix padding and positioning + link up sandbox mode enable to the sidebar checkbox + add toolbar indicator for sandbox state --- .../thunderscope/common/common_widgets.py | 1 + src/software/thunderscope/gl/gl_widget.py | 55 +++-- .../gl/layers/gl_sandbox_world_layer.py | 9 + src/software/thunderscope/gl/sandbox/BUILD | 2 + .../gl/sandbox/gl_sandbox_sidebar.py | 188 +++++++++++++----- src/software/thunderscope/gl/toolbars/BUILD | 2 + .../gl/toolbars/gl_field_toolbar.py | 102 +++++----- .../gl/toolbars/gl_gamecontroller_toolbar.py | 21 +- .../thunderscope/gl/toolbars/gl_toolbar.py | 11 +- src/software/thunderscope/gl/widgets/BUILD | 37 +--- .../thunderscope/widget_setup_functions.py | 42 ++-- 11 files changed, 278 insertions(+), 192 deletions(-) diff --git a/src/software/thunderscope/common/common_widgets.py b/src/software/thunderscope/common/common_widgets.py index e21d2cee07..398a3c1590 100644 --- a/src/software/thunderscope/common/common_widgets.py +++ b/src/software/thunderscope/common/common_widgets.py @@ -4,6 +4,7 @@ from software.py_constants import * from software.thunderscope.util import color_from_gradient from typing import override +import textwrap class FloatSlider(QSlider): diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index a4a1194925..2b114a1c09 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -7,7 +7,7 @@ from pyqtgraph.opengl import * import numpy as np -from typing import Optional +from typing import Callable, Optional from software.thunderscope.common.frametime_counter import FrameTimeCounter from software.thunderscope.constants import * @@ -87,14 +87,6 @@ def __init__( self.setLayout(self.layout) self.layout.addWidget(self.gl_view_widget) - # Setup sandbox sidebar - self.sandbox_sidebar_visible = False - self.sandbox_mode_enabled = False - self.sandbox_mode_callback = None - self.sandbox_sidebar = GLSandboxSidebar( - parent=self.gl_view_widget, on_sandbox_mode_toggle=self.toggle_sandbox_mode - ) - # Setup toolbar self.measure_mode_enabled = False self.measure_layer = None @@ -110,9 +102,17 @@ def __init__( toolbars_menu=self.toolbars_menu, replay_mode=player is not None, on_add_bookmark=self.add_bookmark, - on_toggle_sidebar=self.toggle_sidebar_visibility, ) + # Setup sandbox sidebar + self._sandbox_mode_callbacks: list[Callable[[bool], None]] = [] + self.sandbox_sidebar = GLSandboxSidebar( + parent=self.gl_view_widget, + widget_above=self.simulation_control_toolbar + ) + # let toolbar update the sidebar visibility + self.simulation_control_toolbar.set_sidebar_visibility_callback(self.sandbox_sidebar.toggle_visibility) + # Setup gamecontroller toolbar self.gamecontroller_toolbar = GLGamecontrollerToolbar( parent=self.gl_view_widget, @@ -143,6 +143,10 @@ def get_sim_control_toolbar(self): """Returns the simulation control toolbar""" return self.simulation_control_toolbar + def get_sandbox_sidebar(self): + """Returns the sandbox mode sidebar""" + return self.sandbox_sidebar + @override def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: """Detect when a key has been pressed @@ -277,8 +281,13 @@ def refresh(self) -> None: if self.simulation_control_toolbar: self.simulation_control_toolbar.refresh() + + if self.gamecontroller_toolbar: self.gamecontroller_toolbar.refresh() + if self.sandbox_sidebar: + self.sandbox_sidebar.refresh() + simulation_state = self.simulation_state_buffer.get(block=False) # Don't refresh the layers if the simulation is paused if simulation_state.is_playing: @@ -337,17 +346,6 @@ def toggle_measure_mode(self) -> None: else: self.remove_layer(self.measure_layer) - def toggle_sidebar_visibility(self) -> None: - """Toggles whether the sandbox sidebar is displayed""" - self.sidebar_enabled = not self.sidebar_enabled - self.sandbox_sidebar.toggle_visibility(self.sidebar_enabled) - - def toggle_sandbox_mode(self) -> None: - self.sandbox_mode_enabled = not self.sandbox_mode_enabled - - if self.sandbox_mode_callback: - self.sandbox_mode_callback(self.sandbox_mode_enabled) - def __add_toolbar_toggle(self, toolbar: QWidget, name: str) -> None: """Adds a button to the toolbar menu to toggle the given toolbar @@ -414,3 +412,18 @@ def add_bookmark(self): ) self.proto_unix_io.send_proto(ReplayBookmark, bookmark) success_toast(self.parentWidget(), "Added bookmark!") + + def register_sandbox_mode_callback( + self, callback: Callable[[bool], None] + ) -> None: + """Register a callback that will be called when sandbox mode is toggled. + + :param callback: A callable that takes one boolean argument (the new state). + """ + self._sandbox_mode_callbacks.append(callback) + + def toggle_sandbox_mode(self) -> None: + """Toggle sandbox mode on/off and notify all registered callbacks.""" + self.sandbox_mode_enabled = not self.sandbox_mode_enabled + for callback in self._sandbox_mode_callbacks: + callback(self.sandbox_mode_enabled) diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 907834a772..5507ce6481 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -127,6 +127,8 @@ def __init__( self.undo_operations = [] self.redo_operations = [] + self.sandbox_enabled = False + @override def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: """Requires Ctrl + Shift to be pressed along with mouse click @@ -378,6 +380,13 @@ def reset_to_pre_sim(self) -> None: for robot_id, state in self.pre_sim_robot_positions.items(): self.__update_world_state(robot_id, state[0], state[1]) + def set_sandbox_enabled(self, enabled: bool) -> None: + """Sets the sandbox mode enabled state + + :param enabled: whether sandbox mode is enabled + """ + self.sandbox_enabled = enabled + def __add_undo_operation(self, operation: RobotOperation) -> None: """Adds an undo operation to the list and emits the toggle enable signal diff --git a/src/software/thunderscope/gl/sandbox/BUILD b/src/software/thunderscope/gl/sandbox/BUILD index db8026687f..3da6c24c00 100644 --- a/src/software/thunderscope/gl/sandbox/BUILD +++ b/src/software/thunderscope/gl/sandbox/BUILD @@ -6,6 +6,8 @@ py_library( name = "gl_sandbox_sidebar", srcs = ["gl_sandbox_sidebar.py"], deps = [ + "//software/thunderscope/common:common_widgets", requirement("pyqtgraph"), + requirement("qtawesome") ], ) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 8ba45fdadd..870ef1f331 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -1,9 +1,10 @@ from typing import Callable, override -from pyqtgraph.Qt.QtCore import QEvent +from pyqtgraph.Qt import QtCore from pyqtgraph.Qt.QtWidgets import * - +from proto.import_all_protos import * +from software.thunderscope.thread_safe_buffer import ThreadSafeBuffer import qtawesome as qta -from software.thunderscope.common.common_widgets import ToggleableButton +from software.thunderscope.common.common_widgets import ToggleableButton, StyledButton class GLSandboxSidebar(QWidget): @@ -14,84 +15,100 @@ class GLSandboxSidebar(QWidget): controls vertically. """ - BUTTON_ICON_COLOR = "white" - SIDEBAR_WIDTH_RATIO = 0.3 + CONTENT_COLOR = "white" + BACKGROUND_COLOR = "black" + POSITION_PADDING_MULTIPLIER = 0.1 + SIDEBAR_WIDTH_RATIO = 0.2 def __init__( self, parent: QWidget, - on_sandbox_mode_toggle: Callable[[bool], None] = None, + widget_above: QWidget ): """Set up the sandbox sidebar :param parent: the parent widget to attach this sidebar to - :param on_sandbox_mode_toggle: callback when sandbox mode is toggled on/off + :param widget_above: the widget above the sidebar for placement """ super().__init__(parent=parent) + self.widget_above = widget_above # Setup sidebar with a vertical layout self.setLayout(QVBoxLayout()) self.sidebar_enabled = False - - # Style with a dark background so it's visible when overlaying - self.setStyleSheet( - "background-color: rgba(30, 30, 30, 220);" "border-left: 1px solid #555;" + self.sidebar_rendered = False + self.sandbox_mode_enabled = False + self._sandbox_mode_callbacks: list[Callable[[bool], None]] = [] + + # Create a container widget to hold all sidebar contents + self.sidebar_container = QWidget() + self.sidebar_container.setLayout(QVBoxLayout()) + self.sidebar_container.setObjectName("sidebarContainer") + + # Style with a dark background + border so it's visible when overlaying + self.sidebar_container.setStyleSheet( + f"#sidebarContainer {{" + f" background-color: {self.BACKGROUND_COLOR};" + f" border: 2px solid {self.CONTENT_COLOR};" + f" border-radius: 5px;" + f" padding: 10px;" + f" padding-top: 10px;" + f"}}" ) + self.sidebar_container.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground) + self.sidebar_container.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) # Setup sandbox mode toggle checkbox - self.sandbox_mode_checkbox = QCheckBox("Enable Sandbox Mode") + self.checkbox_layout = QHBoxLayout() + self.checkbox_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.checkbox_layout.addWidget(QLabel("Enable Sandbox Mode: ")) + + self.sandbox_mode_checkbox = QCheckBox() self.sandbox_mode_checkbox.setChecked(False) - self.sandbox_mode_checkbox.stateChanged.connect( - lambda checked: on_sandbox_mode_toggle( - self.sandbox_mode_checkbox.isChecked() - ) - ) - self.layout().addWidget(self.sandbox_mode_checkbox) + self.sandbox_mode_checkbox.stateChanged.connect(self.toggle_sandbox_mode) + self.checkbox_layout.addWidget(self.sandbox_mode_checkbox) + self.sidebar_container.layout().addStretch() + self.sidebar_container.layout().addLayout(self.checkbox_layout) + + # Setup pause button + self.pause_button = ToggleableButton() + self.toggle_pause_button(True) + # buffer for the simulator pause / play state + self.simulation_state_buffer = ThreadSafeBuffer(5, SimulationState) # Setup Undo button self.undo_button = ToggleableButton(False) self.undo_button.setToolTip("Undo") self.undo_button.setIcon( - qta.icon("mdi6.undo-variant", color=self.BUTTON_ICON_COLOR) + qta.icon("mdi6.undo-variant", color=self.CONTENT_COLOR) ) # Setup Redo button self.redo_button = ToggleableButton(False) self.redo_button.setToolTip("Redo") self.redo_button.setIcon( - qta.icon("mdi6.redo-variant", color=self.BUTTON_ICON_COLOR) + qta.icon("mdi6.redo-variant", color=self.CONTENT_COLOR) ) button_layout = QHBoxLayout() + button_layout.addWidget(self.pause_button) button_layout.addWidget(self.undo_button) button_layout.addWidget(self.redo_button) - self.layout().addLayout(button_layout) + self.sidebar_container.layout().addStretch() + self.sidebar_container.layout().addLayout(button_layout) # Setup Clear Field button self.clear_field_button = ToggleableButton(False) - self.clear_field_button.setToolTip("Clear Field") - self.clear_field_button.setIcon( - qta.icon("mdi6.delete-variant", color=self.BUTTON_ICON_COLOR) - ) - self.layout().addWidget(self.clear_field_button) - - # Setup Create new Test button - self.create_test_button = ToggleableButton(False) - self.create_test_button.setToolTip("Create new Test") - self.create_test_button.setIcon( - qta.icon("mdi6.flask-outline", color=self.BUTTON_ICON_COLOR) - ) - self.layout().addWidget(self.create_test_button) + self.clear_field_button.setToolTip("Clears all robots from the field") + self.clear_field_button.setText("Clear Field") + self.sidebar_container.layout().addStretch() + self.sidebar_container.layout().addWidget(self.clear_field_button) - # Setup Add Case to Existing Test button - self.add_case_button = ToggleableButton(False) - self.add_case_button.setToolTip("Add Case to Existing Test") - self.add_case_button.setIcon( - qta.icon("mdi6.test-tube", color=self.BUTTON_ICON_COLOR) - ) - self.layout().addWidget(self.add_case_button) + self.sidebar_container.layout().addStretch() + # Add the container to the main layout, with stretch underneath + self.layout().addWidget(self.sidebar_container) self.layout().addStretch() self.hide() @@ -99,30 +116,91 @@ def __init__( # Listen for parent resize events to reposition parent.installEventFilter(self) + def refresh(self) -> None: + """Refreshes the UI to move the sidebar and update the pause button state""" + if not self.sidebar_rendered: + if self.sidebar_enabled: + self.reposition() + self.show() + else: + self.hide() + self.sidebar_rendered = True + + # update the pause button state + simulation_state = self.simulation_state_buffer.get( + block=False, return_cached=False + ) + if simulation_state: + self.toggle_pause_button(simulation_state.is_playing) + + def register_sandbox_mode_callback( + self, callback: Callable[[bool], None] + ) -> None: + """Register a callback that will be called when sandbox mode is toggled. + + :param callback: A callable that takes one boolean argument (the new state). + """ + self._sandbox_mode_callbacks.append(callback) + + def toggle_sandbox_mode(self) -> None: + """Toggle sandbox mode on/off and notify all registered callbacks.""" + self.sandbox_mode_enabled = not self.sandbox_mode_enabled + for callback in self._sandbox_mode_callbacks: + callback(self.sandbox_mode_enabled) + self.pause_button.toggle_enabled(self.sandbox_mode_enabled) + self.undo_button.toggle_enabled(self.sandbox_mode_enabled) + self.redo_button.toggle_enabled(self.sandbox_mode_enabled) + + def toggle_pause_button(self, is_playing: bool) -> None: + """Toggles the state of the pause button by updating its text and icon + + :param is_playing: True if the button is in the Play state, False if its in the Pause state + """ + self.pause_button.setToolTip("Pause" if is_playing else "Play") + self.pause_button.setIcon( + qta.icon( + "fa6s.pause" if is_playing else "fa5s.play", + color=self.CONTENT_COLOR, + ) + ) + def toggle_visibility(self, enabled: bool): """Toggle the sidebar visibility""" self.sidebar_enabled = enabled - if self.sidebar_enabled: - self.reposition() - self.show() - else: - self.hide() + self.sidebar_rendered = False def reposition(self): """Position to the right edge of the parent, taking full height""" parent = self.parent() - if parent: - width = int(parent.width() * self.SIDEBAR_WIDTH_RATIO) - self.setGeometry( - parent.width() - width, - 0, - width, - parent.height(), - ) + width = int(parent.width() * self.SIDEBAR_WIDTH_RATIO) + self.setGeometry( + parent.geometry().right() - width, + self.widget_above.height(), + width, + parent.height(), + ) + + # self.move(parent.geometry().right() - int((1 + self.POSITION_PADDING_MULTIPLIER) * self.sidebar_container.width()), parent.geometry().top() + self.widget_above.height()) @override def eventFilter(self, obj, event): """Reposition when the parent is resized""" - if obj is self.parent() and event.type() == QEvent.Resize: + if obj is self.parent() and event.type() == QtCore.QEvent.Resize: self.reposition() return super().eventFilter(obj, event) + + def toggle_undo_enabled(self, enabled: bool) -> None: + """Callback function to enable / disable the undo button based on the given state + + :param enabled: if the undo button is enabled or not + """ + self.undo_button.toggle_enabled(enabled) + self.undo_button.repaint() + + def toggle_redo_enabled(self, enabled: bool) -> None: + """Callback function to enable / disable the redo button based on the given state + + :param enabled: if the redo button is enabled or not + """ + self.redo_button.toggle_enabled(enabled) + self.redo_button.repaint() diff --git a/src/software/thunderscope/gl/toolbars/BUILD b/src/software/thunderscope/gl/toolbars/BUILD index 911d6293ca..f22f387e14 100644 --- a/src/software/thunderscope/gl/toolbars/BUILD +++ b/src/software/thunderscope/gl/toolbars/BUILD @@ -28,6 +28,8 @@ py_library( ":gl_toolbar", "//proto:import_all_protos", "//software/networking:ssl_proto_communication", + "//software/thunderscope/gl/widgets:gl_runtime_installer", + "//software/thunderscope/gl/widgets:gl_runtime_selector", "//software/thunderscope/binary_context_managers:game_controller", "//software/thunderscope/common:common_widgets", requirement("pyqtgraph"), diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index aec1a232d7..cd34196ef6 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -14,6 +14,10 @@ from software.thunderscope.gl.toolbars.gl_toolbar import GLToolbar import qtawesome as qta +# Sandbox status label colors +ON_COLOR = "#FF0000" +OFF_COLOR = "#00D000" + class GLFieldToolbar(GLToolbar): """Toolbar for the GL Field Widget @@ -37,9 +41,6 @@ def __init__( """Set up the toolbar with these buttons: - Layers select menu - - Undo - - Pause - - Redo - Help - Measure Mode Toggle - Camera View Select menu @@ -112,10 +113,7 @@ def __init__( ) ) - # Setup pause button - self.pause_button = StyledButton() - self.toggle_pause_button(True) - # buffer for the simulator pause / play state + # buffer for the simulator state self.simulation_state_buffer = ThreadSafeBuffer(5, SimulationState) # Setup Toolbars button for toggling visibility of toolbars @@ -150,48 +148,55 @@ def __init__( lambda new_speed=speed: self.speed_callback(new_speed), ) + self.sandbox_sidebar_visible = False + self.sidebar_visibility_callback = None + + self.sidebar_button_container = QWidget() + sidebar_button_layout = QHBoxLayout() + sidebar_button_layout.setContentsMargins(0, 0, 0, 0) + sidebar_button_layout.setSpacing(4) + + # button to show sidebar self.sandbox_sidebar_button = StyledButton() + self.sandbox_sidebar_button.setText("Sandbox Mode") self.sandbox_sidebar_button.setToolTip("Toggle Sandbox Sidebar") - self.sandbox_sidebar_button.setIcon( - qta.icon("mdi.view-sidebar-outline", color=self.BUTTON_ICON_COLOR) - ) - self.sandbox_sidebar_button.clicked.connect(self.on_toggle_sidebar) + self.sandbox_sidebar_button.clicked.connect(self.toggle_sidebar_visibility) + + # label to indicate sandbox mode state + self.sandbox_sidebar_label = QLabel() + + sidebar_button_layout.addWidget(self.sandbox_sidebar_button) + sidebar_button_layout.addWidget(self.sandbox_sidebar_label) + self.sidebar_button_container.setLayout(sidebar_button_layout) + + # turn off sandbox mode as default + self.set_sandbox_mode_enabled(False) # Setup toolbar self.layout().addWidget(self.layers_button) + self.add_separator(self.layout()) self.layout().addWidget(self.toolbars_button) - self.layout().addStretch() + self.add_separator(self.layout()) self.layout().addWidget(self.help_button) self.layout().addWidget(self.measure_button) self.layout().addWidget(self.camera_view_button) - self.layout().addWidget(self.sandbox_sidebar_button) if not replay_mode: self.layout().addWidget(self.bookmark_button) + + self.layout().addStretch() + self.layout().addWidget(self.sandbox_sidebar_container) @override def refresh(self) -> None: - """Refreshes the UI for all the toolbar icons and updates toolbar position""" - # update the pause button state + """Updates the sim speed""" simulation_state = self.simulation_state_buffer.get( block=False, return_cached=False ) if simulation_state: - self.toggle_pause_button(simulation_state.is_playing) self.update_simulation_speed(simulation_state.simulation_speed) - def toggle_pause_button(self, is_playing: bool) -> None: - """Toggles the state of the pause button by updating its text and icon - - :param is_playing: True if the button is in the Play state, False if its in the Pause state - """ - self.pause_button.setToolTip("Pause" if is_playing else "Play") - self.pause_button.setIcon( - qta.icon( - "fa6s.pause" if is_playing else "fa5s.play", - color=self.BUTTON_ICON_COLOR, - ) - ) + self.setGeometry(0, 0, self.parentWidget().width(), self.height()) def update_simulation_speed(self, speed: float) -> None: """Updates the simulation speed label @@ -200,22 +205,6 @@ def update_simulation_speed(self, speed: float) -> None: """ self.sim_speed_button.setText(f"Speed: {speed:.2f}x") - def toggle_undo_enabled(self, enabled: bool) -> None: - """Callback function to enable / disable the undo button based on the given state - - :param enabled: if the undo button is enabled or not - """ - self.undo_button.toggle_enabled(enabled) - self.undo_button.repaint() - - def toggle_redo_enabled(self, enabled: bool) -> None: - """Callback function to enable / disable the redo button based on the given state - - :param enabled: if the redo button is enabled or not - """ - self.redo_button.toggle_enabled(enabled) - self.redo_button.repaint() - def set_speed_callback(self, callback: Callable[[float], None]) -> None: """Sets the callback function for updating the simulation speed @@ -223,9 +212,26 @@ def set_speed_callback(self, callback: Callable[[float], None]) -> None: """ self.speed_callback = callback - def set_sandbox_toggle_callback(self, callback: Callable[[bool], None]) -> None: - """Sets the callback function for toggling sandbox mode + def set_sidebar_visibility_callback(self, callback: Callable[[float], None]) -> None: + """Sets the callback function for toggling sidebar visibility + + :param callback: the callback function for toggling sidebar visibility + """ + self.sidebar_visibility_callback = callback + + def toggle_sidebar_visibility(self) -> None: + self.sandbox_sidebar_visible = not self.sandbox_sidebar_visible + if self.sidebar_visibility_callback: + self.sidebar_visibility_callback(self.sandbox_sidebar_visible) + + def set_sandbox_mode_enabled(self, enabled: bool) -> None: + """Sets the sandbox enabled state and updates the label - :param callback: the callback function to toggle sandbox mode + :param enabled: whether sandbox mode is enabled """ - self.sandbox_toggle_callback = callback + self.sandbox_enabled = enabled + color = ON_COLOR if enabled else OFF_COLOR + self.sandbox_sidebar_label.setText("On" if enabled else "Off") + self.sandbox_sidebar_label.setStyleSheet( + f"color: {color}; font-weight: bold;" + ) diff --git a/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py index cb591e1516..4a79037864 100644 --- a/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py @@ -10,7 +10,7 @@ from software.thunderscope.gl.widgets.gl_runtime_installer import ( GLRuntimeInstallerDialog, ) -from software.thunderscope.common.common_widgets import ToggleableButton +from software.thunderscope.common.common_widgets import ToggleableButton, StyledButton import qtawesome as qta @@ -118,17 +118,17 @@ def __init__( self.__toggle_normal_start_button() self.layout().addWidget(QLabel("Gamecontroller")) - self.__add_separator(self.layout()) + self.add_separator(self.layout()) self.layout().addWidget(self.stop_button) self.layout().addWidget(self.halt_button) self.layout().addWidget(self.force_start_button) - self.__add_separator(self.layout()) + self.add_separator(self.layout()) self.layout().addWidget(self.plays_menu_button) self.layout().addWidget(self.normal_start_button) - self.__add_separator(self.layout()) + self.add_separator(self.layout()) self.layout().addWidget(self.gc_browser_button) self.layout().addStretch() - self.__add_separator(self.layout()) + self.add_separator(self.layout()) self.layout().addWidget(self.runtime_installer_button) self.layout().addWidget(self.runtime_selector_button) @@ -137,14 +137,6 @@ def refresh(self) -> None: """Refreshes the UI to update toolbar position""" self.move(0, self.parentWidget().geometry().bottom() - self.height()) - def __add_separator(self, layout: QBoxLayout) -> None: - """Adds a separator line with enough spacing to the given layout - - :param layout: the layout to add the separator to - """ - layout.addSpacing(10) - layout.addWidget(QLabel("|")) - layout.addSpacing(10) def __add_plays_menu_items(self, is_blue: bool) -> None: """Initializes the plays menu with the available plays for the given team @@ -223,7 +215,7 @@ def __setup_icon_button( :param display_text: optional param if button needs both text and an icon :return: the button """ - button = ToggleableButton() + button = ToggleableButton(enabled=True) button.setIcon(icon) button.setToolTip(tooltip) button.clicked.connect(callback) @@ -231,7 +223,6 @@ def __setup_icon_button( if display_text: button.setText(display_text) - button.toggle_enabled(True) return button def __send_stop_command(self) -> None: diff --git a/src/software/thunderscope/gl/toolbars/gl_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_toolbar.py index c3fa2970cd..46b7c03bdd 100644 --- a/src/software/thunderscope/gl/toolbars/gl_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_toolbar.py @@ -20,7 +20,7 @@ def __init__(self, parent: QWidget): super(GLToolbar, self).__init__(parent=parent) # Setup toolbar - self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) self.setStyleSheet("background-color: rgba(0,0,0,0);" "padding: 0px;") self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground) self.setLayout(QHBoxLayout()) @@ -28,3 +28,12 @@ def __init__(self, parent: QWidget): def refresh(self) -> None: """Refreshes the UI (overridden by child classes)""" raise NotImplementedError("Subclasses must implement this method!") + + def add_separator(self, layout: QBoxLayout) -> None: + """Adds a separator line with enough spacing to the given layout + + :param layout: the layout to add the separator to + """ + layout.addSpacing(10) + layout.addWidget(QLabel("|")) + layout.addSpacing(10) \ No newline at end of file diff --git a/src/software/thunderscope/gl/widgets/BUILD b/src/software/thunderscope/gl/widgets/BUILD index 1033dacfe6..9d1f0f291f 100644 --- a/src/software/thunderscope/gl/widgets/BUILD +++ b/src/software/thunderscope/gl/widgets/BUILD @@ -20,45 +20,10 @@ py_library( ], ) -py_library( - name = "gl_toolbar", - srcs = ["gl_toolbar.py"], - deps = [ - requirement("pyqtgraph"), - ], -) - py_library( name = "icon_loader", srcs = ["icon_loader.py"], deps = [ requirement("pyqtgraph"), ], -) - -py_library( - name = "gl_field_toolbar", - srcs = ["gl_field_toolbar.py"], - deps = [ - ":gl_toolbar", - "//software/thunderscope/common:common_widgets", - requirement("pyqtgraph"), - requirement("qtawesome"), - ], -) - -py_library( - name = "gl_gamecontroller_toolbar", - srcs = ["gl_gamecontroller_toolbar.py"], - deps = [ - ":gl_runtime_installer", - ":gl_runtime_selector", - ":gl_toolbar", - "//proto:import_all_protos", - "//software/networking:ssl_proto_communication", - "//software/thunderscope/binary_context_managers:game_controller", - "//software/thunderscope/common:common_widgets", - requirement("pyqtgraph"), - requirement("qtawesome"), - ], -) +) \ No newline at end of file diff --git a/src/software/thunderscope/widget_setup_functions.py b/src/software/thunderscope/widget_setup_functions.py index d5c85f5d2b..6c286bb7a8 100644 --- a/src/software/thunderscope/widget_setup_functions.py +++ b/src/software/thunderscope/widget_setup_functions.py @@ -151,23 +151,9 @@ def setup_gl_widget( gl_widget.add_layer(referee_layer) simulation_control_toolbar = gl_widget.get_sim_control_toolbar() - simulation_control_toolbar.set_speed_callback(world_layer.set_simulation_speed) - # connect all sandbox controls if using sandbox mode - simulation_control_toolbar.undo_button.clicked.connect(world_layer.undo) - simulation_control_toolbar.redo_button.clicked.connect(world_layer.redo) - simulation_control_toolbar.reset_button.clicked.connect( - world_layer.reset_to_pre_sim - ) - world_layer.undo_toggle_enabled_signal.connect( - simulation_control_toolbar.toggle_undo_enabled - ) - world_layer.redo_toggle_enabled_signal.connect( - simulation_control_toolbar.toggle_redo_enabled - ) - simulation_control_toolbar.pause_button.clicked.connect( - world_layer.toggle_play_state - ) + sandbox_sidebar = gl_widget.get_sandbox_sidebar() + sim_proto_unix_io.register_observer( SimulationState, simulation_control_toolbar.simulation_state_buffer ) @@ -177,6 +163,30 @@ def setup_gl_widget( sim_proto_unix_io.register_observer( SimulationState, gl_widget.simulation_state_buffer ) + sim_proto_unix_io.register_observer( + SimulationState, sandbox_sidebar.simulation_state_buffer + ) + + simulation_control_toolbar.set_speed_callback(world_layer.set_simulation_speed) + + # connect all sandbox controls + sandbox_sidebar.register_sandbox_mode_callback( + simulation_control_toolbar.set_sandbox_enabled + ) + sandbox_sidebar.register_sandbox_mode_callback( + world_layer.set_sandbox_enabled + ) + sandbox_sidebar.pause_button.clicked.connect( + world_layer.toggle_play_state + ) + sandbox_sidebar.undo_button.clicked.connect(world_layer.undo) + sandbox_sidebar.redo_button.clicked.connect(world_layer.redo) + world_layer.undo_toggle_enabled_signal.connect( + sandbox_sidebar.toggle_undo_enabled + ) + world_layer.redo_toggle_enabled_signal.connect( + sandbox_sidebar.toggle_redo_enabled + ) for arg in [ (World, world_layer.world_buffer), From ce55bf397af4a6295d1fa2cc170fe94d76fb7dcb Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Mon, 29 Jun 2026 21:23:20 -0700 Subject: [PATCH 09/19] format --- src/software/thunderscope/gl/gl_widget.py | 11 ++++----- src/software/thunderscope/gl/sandbox/BUILD | 2 +- .../gl/sandbox/gl_sandbox_sidebar.py | 24 +++++++++---------- src/software/thunderscope/gl/toolbars/BUILD | 4 ++-- .../gl/toolbars/gl_field_toolbar.py | 10 ++++---- .../gl/toolbars/gl_gamecontroller_toolbar.py | 1 - .../thunderscope/gl/toolbars/gl_toolbar.py | 6 +++-- src/software/thunderscope/gl/widgets/BUILD | 2 +- .../thunderscope/widget_setup_functions.py | 18 ++++---------- 9 files changed, 34 insertions(+), 44 deletions(-) diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index 2b114a1c09..38626e721d 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -107,11 +107,12 @@ def __init__( # Setup sandbox sidebar self._sandbox_mode_callbacks: list[Callable[[bool], None]] = [] self.sandbox_sidebar = GLSandboxSidebar( - parent=self.gl_view_widget, - widget_above=self.simulation_control_toolbar + parent=self.gl_view_widget, widget_above=self.simulation_control_toolbar ) # let toolbar update the sidebar visibility - self.simulation_control_toolbar.set_sidebar_visibility_callback(self.sandbox_sidebar.toggle_visibility) + self.simulation_control_toolbar.set_sidebar_visibility_callback( + self.sandbox_sidebar.toggle_visibility + ) # Setup gamecontroller toolbar self.gamecontroller_toolbar = GLGamecontrollerToolbar( @@ -413,9 +414,7 @@ def add_bookmark(self): self.proto_unix_io.send_proto(ReplayBookmark, bookmark) success_toast(self.parentWidget(), "Added bookmark!") - def register_sandbox_mode_callback( - self, callback: Callable[[bool], None] - ) -> None: + def register_sandbox_mode_callback(self, callback: Callable[[bool], None]) -> None: """Register a callback that will be called when sandbox mode is toggled. :param callback: A callable that takes one boolean argument (the new state). diff --git a/src/software/thunderscope/gl/sandbox/BUILD b/src/software/thunderscope/gl/sandbox/BUILD index 3da6c24c00..7ffb54ae1d 100644 --- a/src/software/thunderscope/gl/sandbox/BUILD +++ b/src/software/thunderscope/gl/sandbox/BUILD @@ -8,6 +8,6 @@ py_library( deps = [ "//software/thunderscope/common:common_widgets", requirement("pyqtgraph"), - requirement("qtawesome") + requirement("qtawesome"), ], ) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 870ef1f331..21bd5ce98b 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -4,7 +4,7 @@ from proto.import_all_protos import * from software.thunderscope.thread_safe_buffer import ThreadSafeBuffer import qtawesome as qta -from software.thunderscope.common.common_widgets import ToggleableButton, StyledButton +from software.thunderscope.common.common_widgets import ToggleableButton class GLSandboxSidebar(QWidget): @@ -20,11 +20,7 @@ class GLSandboxSidebar(QWidget): POSITION_PADDING_MULTIPLIER = 0.1 SIDEBAR_WIDTH_RATIO = 0.2 - def __init__( - self, - parent: QWidget, - widget_above: QWidget - ): + def __init__(self, parent: QWidget, widget_above: QWidget): """Set up the sandbox sidebar :param parent: the parent widget to attach this sidebar to @@ -56,8 +52,12 @@ def __init__( f" padding-top: 10px;" f"}}" ) - self.sidebar_container.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground) - self.sidebar_container.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) + self.sidebar_container.setAttribute( + QtCore.Qt.WidgetAttribute.WA_StyledBackground + ) + self.sidebar_container.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred + ) # Setup sandbox mode toggle checkbox self.checkbox_layout = QHBoxLayout() @@ -117,7 +117,7 @@ def __init__( parent.installEventFilter(self) def refresh(self) -> None: - """Refreshes the UI to move the sidebar and update the pause button state""" + """Refreshes the UI to move the sidebar and update the pause button state""" if not self.sidebar_rendered: if self.sidebar_enabled: self.reposition() @@ -125,7 +125,7 @@ def refresh(self) -> None: else: self.hide() self.sidebar_rendered = True - + # update the pause button state simulation_state = self.simulation_state_buffer.get( block=False, return_cached=False @@ -133,9 +133,7 @@ def refresh(self) -> None: if simulation_state: self.toggle_pause_button(simulation_state.is_playing) - def register_sandbox_mode_callback( - self, callback: Callable[[bool], None] - ) -> None: + def register_sandbox_mode_callback(self, callback: Callable[[bool], None]) -> None: """Register a callback that will be called when sandbox mode is toggled. :param callback: A callable that takes one boolean argument (the new state). diff --git a/src/software/thunderscope/gl/toolbars/BUILD b/src/software/thunderscope/gl/toolbars/BUILD index f22f387e14..d1cbd46628 100644 --- a/src/software/thunderscope/gl/toolbars/BUILD +++ b/src/software/thunderscope/gl/toolbars/BUILD @@ -28,10 +28,10 @@ py_library( ":gl_toolbar", "//proto:import_all_protos", "//software/networking:ssl_proto_communication", - "//software/thunderscope/gl/widgets:gl_runtime_installer", - "//software/thunderscope/gl/widgets:gl_runtime_selector", "//software/thunderscope/binary_context_managers:game_controller", "//software/thunderscope/common:common_widgets", + "//software/thunderscope/gl/widgets:gl_runtime_installer", + "//software/thunderscope/gl/widgets:gl_runtime_selector", requirement("pyqtgraph"), requirement("qtawesome"), ], diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index cd34196ef6..f3014a682b 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -183,7 +183,7 @@ def __init__( if not replay_mode: self.layout().addWidget(self.bookmark_button) - + self.layout().addStretch() self.layout().addWidget(self.sandbox_sidebar_container) @@ -212,7 +212,9 @@ def set_speed_callback(self, callback: Callable[[float], None]) -> None: """ self.speed_callback = callback - def set_sidebar_visibility_callback(self, callback: Callable[[float], None]) -> None: + def set_sidebar_visibility_callback( + self, callback: Callable[[float], None] + ) -> None: """Sets the callback function for toggling sidebar visibility :param callback: the callback function for toggling sidebar visibility @@ -232,6 +234,4 @@ def set_sandbox_mode_enabled(self, enabled: bool) -> None: self.sandbox_enabled = enabled color = ON_COLOR if enabled else OFF_COLOR self.sandbox_sidebar_label.setText("On" if enabled else "Off") - self.sandbox_sidebar_label.setStyleSheet( - f"color: {color}; font-weight: bold;" - ) + self.sandbox_sidebar_label.setStyleSheet(f"color: {color}; font-weight: bold;") diff --git a/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py index 4a79037864..ee2fb5f46a 100644 --- a/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_gamecontroller_toolbar.py @@ -137,7 +137,6 @@ def refresh(self) -> None: """Refreshes the UI to update toolbar position""" self.move(0, self.parentWidget().geometry().bottom() - self.height()) - def __add_plays_menu_items(self, is_blue: bool) -> None: """Initializes the plays menu with the available plays for the given team diff --git a/src/software/thunderscope/gl/toolbars/gl_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_toolbar.py index 46b7c03bdd..466b9fda4b 100644 --- a/src/software/thunderscope/gl/toolbars/gl_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_toolbar.py @@ -20,7 +20,9 @@ def __init__(self, parent: QWidget): super(GLToolbar, self).__init__(parent=parent) # Setup toolbar - self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) + self.setSizePolicy( + QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed + ) self.setStyleSheet("background-color: rgba(0,0,0,0);" "padding: 0px;") self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground) self.setLayout(QHBoxLayout()) @@ -36,4 +38,4 @@ def add_separator(self, layout: QBoxLayout) -> None: """ layout.addSpacing(10) layout.addWidget(QLabel("|")) - layout.addSpacing(10) \ No newline at end of file + layout.addSpacing(10) diff --git a/src/software/thunderscope/gl/widgets/BUILD b/src/software/thunderscope/gl/widgets/BUILD index 9d1f0f291f..ca4c1b29fb 100644 --- a/src/software/thunderscope/gl/widgets/BUILD +++ b/src/software/thunderscope/gl/widgets/BUILD @@ -26,4 +26,4 @@ py_library( deps = [ requirement("pyqtgraph"), ], -) \ No newline at end of file +) diff --git a/src/software/thunderscope/widget_setup_functions.py b/src/software/thunderscope/widget_setup_functions.py index 6c286bb7a8..81d84dbb74 100644 --- a/src/software/thunderscope/widget_setup_functions.py +++ b/src/software/thunderscope/widget_setup_functions.py @@ -153,7 +153,7 @@ def setup_gl_widget( simulation_control_toolbar = gl_widget.get_sim_control_toolbar() sandbox_sidebar = gl_widget.get_sandbox_sidebar() - + sim_proto_unix_io.register_observer( SimulationState, simulation_control_toolbar.simulation_state_buffer ) @@ -173,20 +173,12 @@ def setup_gl_widget( sandbox_sidebar.register_sandbox_mode_callback( simulation_control_toolbar.set_sandbox_enabled ) - sandbox_sidebar.register_sandbox_mode_callback( - world_layer.set_sandbox_enabled - ) - sandbox_sidebar.pause_button.clicked.connect( - world_layer.toggle_play_state - ) + sandbox_sidebar.register_sandbox_mode_callback(world_layer.set_sandbox_enabled) + sandbox_sidebar.pause_button.clicked.connect(world_layer.toggle_play_state) sandbox_sidebar.undo_button.clicked.connect(world_layer.undo) sandbox_sidebar.redo_button.clicked.connect(world_layer.redo) - world_layer.undo_toggle_enabled_signal.connect( - sandbox_sidebar.toggle_undo_enabled - ) - world_layer.redo_toggle_enabled_signal.connect( - sandbox_sidebar.toggle_redo_enabled - ) + world_layer.undo_toggle_enabled_signal.connect(sandbox_sidebar.toggle_undo_enabled) + world_layer.redo_toggle_enabled_signal.connect(sandbox_sidebar.toggle_redo_enabled) for arg in [ (World, world_layer.world_buffer), From ae58242b8fd0ee94bff126d49facff62a79e350e Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Tue, 30 Jun 2026 00:34:56 -0700 Subject: [PATCH 10/19] added help for sandbox controls + enable status indicator --- .../thunderscope/common/common_widgets.py | 3 ++ src/software/thunderscope/constants.py | 25 ++++++++--- src/software/thunderscope/gl/gl_widget.py | 16 +------ .../gl/layers/gl_sandbox_world_layer.py | 42 ++++++++++++++++--- .../gl/sandbox/gl_sandbox_sidebar.py | 40 ++++++++++++++---- .../gl/toolbars/gl_field_toolbar.py | 33 ++++----------- .../thunderscope/widget_setup_functions.py | 3 +- 7 files changed, 103 insertions(+), 59 deletions(-) diff --git a/src/software/thunderscope/common/common_widgets.py b/src/software/thunderscope/common/common_widgets.py index 398a3c1590..dd6c33bed8 100644 --- a/src/software/thunderscope/common/common_widgets.py +++ b/src/software/thunderscope/common/common_widgets.py @@ -236,6 +236,9 @@ def toggle_enabled(self, enabled: bool): self.enabled = enabled self.setStyleSheet(get_toggle_button_style(enabled)) + def is_enabled(self): + return self.enabled + @override def enterEvent(self, event) -> None: """Sets the cursor to depending on if the button is enabled diff --git a/src/software/thunderscope/constants.py b/src/software/thunderscope/constants.py index 075cff412b..26156503a0 100644 --- a/src/software/thunderscope/constants.py +++ b/src/software/thunderscope/constants.py @@ -25,13 +25,13 @@ class IndividualRobotMode(IntEnum): AI = 2 -class CameraView(Enum): +class CameraView(StrEnum): """Enum for preset camera views in the 3D visualizer""" - ORTHOGRAPHIC = 1 - LANDSCAPE_HIGH_ANGLE = 2 - LEFT_HALF_HIGH_ANGLE = 3 - RIGHT_HALF_HIGH_ANGLE = 4 + ORTHOGRAPHIC = "Orthographic Top Down" + LANDSCAPE_HIGH_ANGLE = "Landscape High Angle" + LEFT_HALF_HIGH_ANGLE = "Left Half High Angle" + RIGHT_HALF_HIGH_ANGLE = "Right Half High Angle" class EstopMode(IntEnum): @@ -194,6 +194,21 @@ class EstopMode(IntEnum): """ ) +SANDBOX_MODE_HELP_TEXT = textwrap.dedent( + """ +

Sandbox Mode Controls


+
+ All Sandbox Mode controls need Ctrl + Shift to work.
+
+ Double Click: Add a new robot or remove an existing one
+ Click and Drag: Move an existing robot around the field
+
+ Undo: Undoes the last operation
+ Redo: Redoes the last operation
+ Clear Field: Removes all robots from field
+ """ +) + THUNDERSCOPE_UI_FONT_NAME = "Roboto" diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index 38626e721d..883bd8e458 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -7,7 +7,7 @@ from pyqtgraph.opengl import * import numpy as np -from typing import Callable, Optional +from typing import Optional from software.thunderscope.common.frametime_counter import FrameTimeCounter from software.thunderscope.constants import * @@ -105,7 +105,6 @@ def __init__( ) # Setup sandbox sidebar - self._sandbox_mode_callbacks: list[Callable[[bool], None]] = [] self.sandbox_sidebar = GLSandboxSidebar( parent=self.gl_view_widget, widget_above=self.simulation_control_toolbar ) @@ -413,16 +412,3 @@ def add_bookmark(self): ) self.proto_unix_io.send_proto(ReplayBookmark, bookmark) success_toast(self.parentWidget(), "Added bookmark!") - - def register_sandbox_mode_callback(self, callback: Callable[[bool], None]) -> None: - """Register a callback that will be called when sandbox mode is toggled. - - :param callback: A callable that takes one boolean argument (the new state). - """ - self._sandbox_mode_callbacks.append(callback) - - def toggle_sandbox_mode(self) -> None: - """Toggle sandbox mode on/off and notify all registered callbacks.""" - self.sandbox_mode_enabled = not self.sandbox_mode_enabled - for callback in self._sandbox_mode_callbacks: - callback(self.sandbox_mode_enabled) diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 5507ce6481..12880bbcd7 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -142,6 +142,10 @@ def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: # forward event to super method for ball placement super().mouse_in_scene_pressed(event) + # if sandbox mode is disabled, don't do anything + if not self.sandbox_enabled: + return + # only allow robot editing if Ctrl + Shift is pressed to avoid conflicting with the ball placement if not event.mouse_event.modifiers() & Qt.KeyboardModifier.ControlModifier: return @@ -173,6 +177,10 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: """ super().mouse_in_scene_dragged(event) + # if sandbox mode is disabled, don't do anything + if not self.sandbox_enabled: + return + # only allow robot editing if Ctrl + Shift is pressed to avoid conflicting with the ball placement if not event.mouse_event.modifiers() & Qt.KeyboardModifier.ControlModifier: return @@ -225,6 +233,10 @@ def mouse_in_scene_released(self, event: MouseInSceneEvent) -> None: """ super().mouse_in_scene_released(event) + # if sandbox mode is disabled, don't do anything + if not self.sandbox_enabled: + return + # ends the currently happening move self.selected_robot_id = None self.selected_robot_pos = None @@ -325,8 +337,8 @@ def clear_field(self) -> None: id=robot_.id, prev_pos=None, pos=QVector3D( - robot.current_state.global_position.x_meters, - robot.current_state.global_position.y_meters, + robot_.current_state.global_position.x_meters, + robot_.current_state.global_position.y_meters, 0, ), next_id=self.next_id, @@ -346,7 +358,7 @@ def clear_field(self) -> None: ) if operations: - self.undo_operations.append(GroupOperation(operations=operations)) + self.undo_operations.append(GroupOperation(operations=operations.values())) self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) # clear internal state @@ -358,8 +370,9 @@ def clear_field(self) -> None: self.redo_operations.clear() self.redo_toggle_enabled_signal.emit(False) - # send empty world state - self.simulator_io.send_proto(WorldState, WorldState()) + # send out empty world state + world_state = WorldState() + self.simulator_io.send_proto(WorldState, world_state) @override def toggle_play_state(self) -> bool: @@ -381,12 +394,18 @@ def reset_to_pre_sim(self) -> None: self.__update_world_state(robot_id, state[0], state[1]) def set_sandbox_enabled(self, enabled: bool) -> None: - """Sets the sandbox mode enabled state + """Sets the sandbox mode enabled state and syncs undo / redo enable state :param enabled: whether sandbox mode is enabled """ self.sandbox_enabled = enabled + # resync undo / redo enabled state once sandbox mode is enabled + # as enabling sandbox mode enables the buttons + if self.sandbox_enabled: + self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) + self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) + def __add_undo_operation(self, operation: RobotOperation) -> None: """Adds an undo operation to the list and emits the toggle enable signal @@ -406,6 +425,16 @@ def __undo_redo_internal(self, operation: RobotOperation) -> None: operation.id, operation.pos, self.DEFAULT_ROBOT_ANGLE, clear_redo=False ) + def __get_empty_world_state(self) -> WorldState: + """Constructs an empty WorldState with just the ball state filled in + from the cached world state + + :return: the empty world state with ball state + """ + world_state = WorldState() + world_state.ball_state.CopyFrom(self.cached_world.ball.current_state) + return world_state + # # # # # # # # # # # # # # # # # # # # # # # # # # ADD / REMOVE / MOVE ROBOT METHODS # # # # # # # # # # # # # # # # # # # # # # # # # # @@ -707,6 +736,7 @@ def __update_with_new_position( # remove an existing robot self.curr_robot_ids.remove(robot_id) del self.pre_sim_robot_positions[robot_id] + self.local_robot_positions[robot_id] = None world_state = self.__remove_robot_from_state(world_state, robot_id) return world_state diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 21bd5ce98b..e14b272580 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -4,7 +4,8 @@ from proto.import_all_protos import * from software.thunderscope.thread_safe_buffer import ThreadSafeBuffer import qtawesome as qta -from software.thunderscope.common.common_widgets import ToggleableButton +from software.thunderscope.common.common_widgets import ToggleableButton, StyledButton +from software.thunderscope.constants import SANDBOX_MODE_HELP_TEXT class GLSandboxSidebar(QWidget): @@ -48,7 +49,7 @@ def __init__(self, parent: QWidget, widget_above: QWidget): f" background-color: {self.BACKGROUND_COLOR};" f" border: 2px solid {self.CONTENT_COLOR};" f" border-radius: 5px;" - f" padding: 10px;" + f" padding: 15px 10px;" f" padding-top: 10px;" f"}}" ) @@ -71,13 +72,30 @@ def __init__(self, parent: QWidget, widget_above: QWidget): self.sidebar_container.layout().addStretch() self.sidebar_container.layout().addLayout(self.checkbox_layout) + self.help_layout = QHBoxLayout() + self.help_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.help_layout.addWidget(QLabel("How to Use: ")) + + self.help_button = StyledButton() + self.help_button.setToolTip("Help") + self.help_button.setIcon(qta.icon("mdi6.help-circle", color=self.CONTENT_COLOR)) + self.help_layout.addWidget(self.help_button) + self.help_button.clicked.connect( + lambda: QMessageBox.information( + self.window(), "Help", SANDBOX_MODE_HELP_TEXT + ) + ) + self.sidebar_container.layout().addStretch() + self.sidebar_container.layout().addLayout(self.help_layout) + # Setup pause button - self.pause_button = ToggleableButton() + self.pause_button = ToggleableButton(False) self.toggle_pause_button(True) # buffer for the simulator pause / play state self.simulation_state_buffer = ThreadSafeBuffer(5, SimulationState) # Setup Undo button + self.undo_button_enabled = False self.undo_button = ToggleableButton(False) self.undo_button.setToolTip("Undo") self.undo_button.setIcon( @@ -85,6 +103,7 @@ def __init__(self, parent: QWidget, widget_above: QWidget): ) # Setup Redo button + self.redo_button_enabled = False self.redo_button = ToggleableButton(False) self.redo_button.setToolTip("Redo") self.redo_button.setIcon( @@ -145,9 +164,16 @@ def toggle_sandbox_mode(self) -> None: self.sandbox_mode_enabled = not self.sandbox_mode_enabled for callback in self._sandbox_mode_callbacks: callback(self.sandbox_mode_enabled) + self.pause_button.toggle_enabled(self.sandbox_mode_enabled) - self.undo_button.toggle_enabled(self.sandbox_mode_enabled) - self.redo_button.toggle_enabled(self.sandbox_mode_enabled) + self.clear_field_button.toggle_enabled(self.sandbox_mode_enabled) + + self.undo_button.toggle_enabled( + self.undo_button_enabled and self.sandbox_mode_enabled + ) + self.redo_button.toggle_enabled( + self.redo_button_enabled and self.sandbox_mode_enabled + ) def toggle_pause_button(self, is_playing: bool) -> None: """Toggles the state of the pause button by updating its text and icon @@ -178,8 +204,6 @@ def reposition(self): parent.height(), ) - # self.move(parent.geometry().right() - int((1 + self.POSITION_PADDING_MULTIPLIER) * self.sidebar_container.width()), parent.geometry().top() + self.widget_above.height()) - @override def eventFilter(self, obj, event): """Reposition when the parent is resized""" @@ -192,6 +216,7 @@ def toggle_undo_enabled(self, enabled: bool) -> None: :param enabled: if the undo button is enabled or not """ + self.undo_button_enabled = enabled self.undo_button.toggle_enabled(enabled) self.undo_button.repaint() @@ -200,5 +225,6 @@ def toggle_redo_enabled(self, enabled: bool) -> None: :param enabled: if the redo button is enabled or not """ + self.redo_button_enabled = enabled self.redo_button.toggle_enabled(enabled) self.redo_button.repaint() diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index f3014a682b..1c9ee8b97a 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -15,8 +15,8 @@ import qtawesome as qta # Sandbox status label colors -ON_COLOR = "#FF0000" -OFF_COLOR = "#00D000" +ON_COLOR = "#00D000" +OFF_COLOR = "#FF0000" class GLFieldToolbar(GLToolbar): @@ -31,7 +31,6 @@ def __init__( parent: QWidget, on_camera_view_change: Callable[[CameraView], None], on_measure_mode: Callable[[], None], - on_toggle_sidebar: Callable[[], None], layers_menu: QMenu, toolbars_menu: QMenu, sandbox_mode: bool = False, @@ -45,7 +44,6 @@ def __init__( - Measure Mode Toggle - Camera View Select menu - Add bookmark - - Toggle sandbox sidebar :param parent: the parent to overlay this toolbar over :param on_camera_view_change: the callback function for when the camera view is changed @@ -54,7 +52,6 @@ def __init__( :param toolbars_menu: the QMenu for the toolbars menu selection :param replay_mode: if replay mode is enabled :param on_add_bookmark: the callback function when adding a bookmark - :param on_toggle_sidebar: the callback function when toggling the sandbox sidebar """ super(GLFieldToolbar, self).__init__(parent=parent) @@ -71,25 +68,11 @@ def __init__( ) self.camera_view_menu = QMenu() self.camera_view_button.setMenu(self.camera_view_menu) - self.camera_view_actions = [ - QtGui.QAction("[1] Orthographic Top Down"), - QtGui.QAction("[2] Landscape High Angle"), - QtGui.QAction("[3] Left Half High Angle"), - QtGui.QAction("[4] Right Half High Angle"), - ] - self.camera_view_actions[0].triggered.connect( - lambda: on_camera_view_change(CameraView.ORTHOGRAPHIC) - ) - self.camera_view_actions[1].triggered.connect( - lambda: on_camera_view_change(CameraView.LANDSCAPE_HIGH_ANGLE) - ) - self.camera_view_actions[2].triggered.connect( - lambda: on_camera_view_change(CameraView.LEFT_HALF_HIGH_ANGLE) - ) - self.camera_view_actions[3].triggered.connect( - lambda: on_camera_view_change(CameraView.RIGHT_HALF_HIGH_ANGLE) - ) - for camera_view_action in self.camera_view_actions: + for idx, camera_view in enumerate(CameraView): + camera_view_action = QtGui.QAction(f"[{idx}] {camera_view.value}") + camera_view_action.triggered.connect( + lambda: on_camera_view_change(camera_view) + ) self.camera_view_menu.addAction(camera_view_action) # Setup Measure button for enabling/disabling measure mode @@ -185,7 +168,7 @@ def __init__( self.layout().addWidget(self.bookmark_button) self.layout().addStretch() - self.layout().addWidget(self.sandbox_sidebar_container) + self.layout().addWidget(self.sidebar_button_container) @override def refresh(self) -> None: diff --git a/src/software/thunderscope/widget_setup_functions.py b/src/software/thunderscope/widget_setup_functions.py index 81d84dbb74..da32420405 100644 --- a/src/software/thunderscope/widget_setup_functions.py +++ b/src/software/thunderscope/widget_setup_functions.py @@ -171,12 +171,13 @@ def setup_gl_widget( # connect all sandbox controls sandbox_sidebar.register_sandbox_mode_callback( - simulation_control_toolbar.set_sandbox_enabled + simulation_control_toolbar.set_sandbox_mode_enabled ) sandbox_sidebar.register_sandbox_mode_callback(world_layer.set_sandbox_enabled) sandbox_sidebar.pause_button.clicked.connect(world_layer.toggle_play_state) sandbox_sidebar.undo_button.clicked.connect(world_layer.undo) sandbox_sidebar.redo_button.clicked.connect(world_layer.redo) + sandbox_sidebar.clear_field_button.clicked.connect(world_layer.clear_field) world_layer.undo_toggle_enabled_signal.connect(sandbox_sidebar.toggle_undo_enabled) world_layer.redo_toggle_enabled_signal.connect(sandbox_sidebar.toggle_redo_enabled) From 27a6013db882b394aa5c4b8790c438b203e8b1b2 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Tue, 30 Jun 2026 09:11:23 -0700 Subject: [PATCH 11/19] fix bugs with clearing field, undoing clear field + made goalie assignment dynamic based on least index if assigned goalie not present + fixed bug with paused sim graphic repainting + block removing last robot --- src/software/sensor_fusion/sensor_fusion.cpp | 39 +++- src/software/sensor_fusion/sensor_fusion.h | 12 + .../gl/layers/gl_sandbox_world_layer.py | 219 ++++++++++++------ .../thunderscope/gl/layers/gl_world_layer.py | 5 +- 4 files changed, 192 insertions(+), 83 deletions(-) diff --git a/src/software/sensor_fusion/sensor_fusion.cpp b/src/software/sensor_fusion/sensor_fusion.cpp index 42296afa2d..7b8e5d5dc1 100644 --- a/src/software/sensor_fusion/sensor_fusion.cpp +++ b/src/software/sensor_fusion/sensor_fusion.cpp @@ -63,20 +63,24 @@ void SensorFusion::processSensorProto(const SensorProto& sensor_msg) updateWorld(sensor_msg.robot_status_msgs()); - friendly_team.assignGoalie(friendly_goalie_id); - enemy_team.assignGoalie(enemy_goalie_id); + unsigned int unresolved_friendly_goalie_id = friendly_goalie_id; + unsigned int unresolved_enemy_goalie_id = enemy_goalie_id; if (sensor_fusion_config.override_game_controller_friendly_goalie_id()) { RobotId friendly_goalie_id_override = sensor_fusion_config.friendly_goalie_id(); - friendly_team.assignGoalie(friendly_goalie_id_override); + unresolved_friendly_goalie_id = friendly_goalie_id_override; } if (sensor_fusion_config.override_game_controller_enemy_goalie_id()) { RobotId enemy_goalie_id_override = sensor_fusion_config.enemy_goalie_id(); - enemy_team.assignGoalie(enemy_goalie_id_override); + unresolved_enemy_goalie_id = enemy_goalie_id_override; } + + friendly_team.assignGoalie( + resolveGoalieId(friendly_team, unresolved_friendly_goalie_id)); + enemy_team.assignGoalie(resolveGoalieId(enemy_team, unresolved_enemy_goalie_id)); } @@ -470,6 +474,33 @@ BallDetection SensorFusion::invert(BallDetection ball_detection) const return ball_detection; } +unsigned int SensorFusion::resolveGoalieId(const Team& team, unsigned int goalie_id) +{ + // If the desired goalie exists on the team, use it + if (team.getRobotById(goalie_id).has_value()) + { + return goalie_id; + } + + // Otherwise, find the lowest robot ID present on the team + const auto& all_robots = team.getAllRobots(); + if (!all_robots.empty()) + { + RobotId lowest_id = all_robots.front().id(); + for (const auto& robot : all_robots) + { + if (robot.id() < lowest_id) + { + lowest_id = robot.id(); + } + } + return lowest_id; + } + + // If there are no robots on the team yet, fall back to the desired goalie ID + return goalie_id; +} + bool SensorFusion::teamHasBall(const Team& team, const Ball& ball) { for (const auto& robot : team.getAllRobots()) diff --git a/src/software/sensor_fusion/sensor_fusion.h b/src/software/sensor_fusion/sensor_fusion.h index 63f47cbada..4ff47e153f 100644 --- a/src/software/sensor_fusion/sensor_fusion.h +++ b/src/software/sensor_fusion/sensor_fusion.h @@ -157,6 +157,18 @@ class SensorFusion */ static bool teamHasBall(const Team& team, const Ball& ball); + /** + * Given a team and a desired goalie ID, returns the desired ID if a robot with + * that ID exists on the team. Otherwise, returns the lowest robot ID present on + * the team. If the team has no robots, returns the desired goalie ID as a fallback. + * + * @param team The team to check + * @param goalie_id The desired goalie ID + * + * @return The goalie ID to use + */ + static unsigned int resolveGoalieId(const Team& team, unsigned int goalie_id); + /** * This function controls the behavior of how the ball is being updated. If this * returns True, we use the position of the robot that triggers the breakbeam instead diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 12880bbcd7..9cf9d2d01f 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -10,6 +10,7 @@ from software.thunderscope.gl.layers.gl_world_layer import GLWorldLayer from software.thunderscope.gl.helpers.extended_gl_view_widget import MouseInSceneEvent from software.thunderscope.proto_unix_io import ProtoUnixIO +from software.thunderscope.constants import Colors, DepthValues class Operation(ABC): @@ -72,6 +73,10 @@ class EnemyAtMousePositionError(Exception): pass +class LastRobotRemoveError(Exception): + pass + + class GLSandboxWorldLayer(GLWorldLayer): """GLWorldLayer that adds functionality to add, remove, and change the state of the robots on the field""" @@ -80,6 +85,8 @@ class GLSandboxWorldLayer(GLWorldLayer): DEFAULT_ROBOT_ANGLE = 0 + MIN_ROBOT_ID = 0 + def __init__( self, name: str, @@ -119,10 +126,6 @@ def __init__( # (easier to keep track of robots rather than removing the entry entirely) self.local_robot_positions: dict[int, tuple[QVector3D, float]] = {} - # the state of robots before running the simulator - # the robot state if only manual moves are considered - self.pre_sim_robot_positions: dict[int, tuple[QVector3D, float]] = {} - # stacks for undo and redo operations self.undo_operations = [] self.redo_operations = [] @@ -162,7 +165,17 @@ def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: self.__handle_new_robot_event(event) else: # if a robot was clicked - self.__handle_existing_robot_event(event, robot_id, index) + try: + self.__handle_existing_robot_event(event, robot_id, index) + except LastRobotRemoveError: + # if the user attempted to remove the last robot + # self.__display_last_remove_warning(event) + return + + # robots are normally auto-rendered by refresh fn when sim is unpaused + # when sim is paused, have to manually render + if not self.is_playing: + self._update_robots_graphics() @override def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: @@ -225,6 +238,11 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: self.selected_robot_id, point_on_current_plane, self.DEFAULT_ROBOT_ANGLE ) + # robots are normally auto-rendered by refresh fn when sim is unpaused + # when sim is paused, have to manually render + if not self.is_playing: + self._update_robots_graphics() + @override def mouse_in_scene_released(self, event: MouseInSceneEvent) -> None: """Reset the selected robot and the in progress move @@ -258,14 +276,6 @@ def refresh_graphics(self) -> None: # for robots in the world, add the ids them to curr robots for robot in self.cached_world.friendly_team.team_robots: self.curr_robot_ids.add(robot.id) - self.pre_sim_robot_positions[robot.id] = ( - QVector3D( - robot.current_state.global_position.x_meters, - robot.current_state.global_position.y_meters, - 0, - ), - robot.current_state.global_orientation.radians, - ) self.next_id = len(self.curr_robot_ids) self.should_init_curr_robot_ids = False @@ -285,16 +295,17 @@ def undo(self) -> None: self.redo_operations.append(operation.reverse(self.next_id)) # apply the operation - if isinstance(operation, GroupOperation): - for op in operation.operations: - self.__undo_redo_internal(op) - else: - self.__undo_redo_internal(operation) + self.__undo_redo_internal(operation) # enable / disable the undo and redo buttons self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) + # robots are normally auto-rendered by refresh fn when sim is unpaused + # when sim is paused, have to manually render + if not self.is_playing: + self._update_robots_graphics() + def redo(self) -> None: """Redoes the last undo operation Adds a corresponding opposite move to the undo list so we can undo if necessary @@ -310,16 +321,17 @@ def redo(self) -> None: self.undo_operations.append(operation.reverse(self.next_id)) # apply the operation - if isinstance(operation, GroupOperation): - for op in operation.operations: - self.__undo_redo_internal(op) - else: - self.__undo_redo_internal(operation) + self.__undo_redo_internal(operation) # enable / disable the undo and redo buttons self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) + # robots are normally auto-rendered by refresh fn when sim is unpaused + # when sim is paused, have to manually render + if not self.is_playing: + self._update_robots_graphics() + def clear_field(self) -> None: """Removes all robots from the field. Adds a GroupOperation to the undo list that can restore all robots. @@ -345,15 +357,17 @@ def clear_field(self) -> None: ) # then, update with local state - for robot_id, pos in self.local_robot_positions.items(): + for robot_id, pos_and_orient in self.local_robot_positions.items(): # if the local robot has already been removed, skip it - if pos is None: + if pos_and_orient is None: continue + position, _ = pos_and_orient + operations[robot_id] = RobotOperation( id=robot_id, prev_pos=None, - pos=pos, + pos=position, next_id=self.next_id, ) @@ -363,7 +377,6 @@ def clear_field(self) -> None: # clear internal state self.curr_robot_ids.clear() - self.pre_sim_robot_positions.clear() self.local_robot_positions.clear() # clear redo list since this is a new action @@ -371,9 +384,15 @@ def clear_field(self) -> None: self.redo_toggle_enabled_signal.emit(False) # send out empty world state - world_state = WorldState() + world_state = self.__get_empty_world_state() + self.simulator_io.send_proto(WorldState, world_state) + # robots are normally auto-rendered by refresh fn when sim is unpaused + # when sim is paused, have to manually render + if not self.is_playing: + self._update_robots_graphics() + @override def toggle_play_state(self) -> bool: """When the simulator is paused / played, reset the local positions @@ -388,11 +407,6 @@ def toggle_play_state(self) -> bool: return curr_play_state - def reset_to_pre_sim(self) -> None: - """Resets all robot positions to what they were before the simulator ran""" - for robot_id, state in self.pre_sim_robot_positions.items(): - self.__update_world_state(robot_id, state[0], state[1]) - def set_sandbox_enabled(self, enabled: bool) -> None: """Sets the sandbox mode enabled state and syncs undo / redo enable state @@ -414,25 +428,55 @@ def __add_undo_operation(self, operation: RobotOperation) -> None: self.undo_operations.append(operation) self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) - def __undo_redo_internal(self, operation: RobotOperation) -> None: - """Helper method to apply a RobotOperation + def __undo_redo_internal(self, operation: Operation) -> None: + """Helper method to apply an Operation Updates robot positions and the next id :param operation: the operation to apply """ - self.next_id = operation.next_id - self.__update_world_state( - operation.id, operation.pos, self.DEFAULT_ROBOT_ANGLE, clear_redo=False - ) + if isinstance(operation, GroupOperation): + self.next_id = float("inf") + + world_state = self.__get_curr_world_state() + + for inner_op in operation.operations: + self.next_id = int(min(self.next_id, inner_op.next_id)) + world_state = self.__update_with_new_position( + world_state, inner_op.id, inner_op.pos, self.DEFAULT_ROBOT_ANGLE + ) + + # send out world state + self.simulator_io.send_proto(WorldState, world_state) + else: + self.next_id = operation.next_id + self.__update_world_state( + operation.id, operation.pos, self.DEFAULT_ROBOT_ANGLE, clear_redo=False + ) def __get_empty_world_state(self) -> WorldState: - """Constructs an empty WorldState with just the ball state filled in - from the cached world state + """Constructs a WorldState with just the ball state filled in + from the cached world state and 1 robot placed at + the edge of the center circle on the half line + + Replaces all local states as well - :return: the empty world state with ball state + :return: the base world state with ball state and 1 robot """ world_state = WorldState() world_state.ball_state.CopyFrom(self.cached_world.ball.current_state) + + center_circle_radius = self.cached_world.field.center_circle_radius + robot_pos = QVector3D(-center_circle_radius, 0, 0) + + for robot_id in self.local_robot_positions.keys(): + self.local_robot_positions[robot_id] = None + + world_state = self.__update_with_new_position( + world_state, self.MIN_ROBOT_ID, robot_pos, self.DEFAULT_ROBOT_ANGLE + ) + + self.next_id = self.MIN_ROBOT_ID + 1 + return world_state # # # # # # # # # # # # # # # # # # # # # # # # # @@ -462,6 +506,11 @@ def __handle_existing_robot_event( self.robot_remove_double_click and self.robot_remove_double_click == event.multi_plane_points[index] ): + # prevent removing the last robot + if len(self.curr_robot_ids) <= 1: + self.__toggle_robot_remove_double_click() + raise LastRobotRemoveError("Trying to remove the final robot!") + # add an undo operation to add back the robot self.__add_undo_operation( RobotOperation( @@ -481,6 +530,18 @@ def __handle_existing_robot_event( self.robot_remove_double_click = event.multi_plane_points[index] QTimer.singleShot(500, self.__toggle_robot_remove_double_click) + def __display_last_remove_warning(self, event: MouseInSceneEvent) -> None: + warning = GLTextItem(font=GLWorldLayer.TEXT_GRAPHICS_QFONT, color=Colors.RED) + warning.show() + warning.setDepthValue(DepthValues.ABOVE_FOREGROUND_DEPTH) + warning.setData( + text="Can't remove last robot!", + pos=[ + event.position().x() - int(warning.width() / 2), + event.position().y() + int(warning.height() * 1.1), + ], + ) + def __handle_new_robot_event(self, event: MouseInSceneEvent) -> None: """Handles a mouse event when an empty position is clicked If double clicked, adds a new robot at that position @@ -567,7 +628,7 @@ def __add_robot_to_state( def __remove_robot_from_state(self, world_state: WorldState, id: int) -> WorldState: """Removes a robot with the given id from the right team in the given world state - Based on current team color + Based on current team color, then shifts down all higher robot IDs to fill the gap :param world_state: the world state to remove robot from :param id: the id of the robot to remove @@ -647,23 +708,7 @@ def __identify_robot_helper( return index return None - def __update_world_state( - self, - new_robot_id: int, - new_pos: Optional[QVector3D], - new_orientation: float, - clear_redo=True, - ) -> None: - """Send out a WorldState proto with the existing robots - If new position is provided, adds a robot with the given id at the given position - Else, removes the robot with the given id from the robot state - - :param new_robot_id: the id of the robot to add / remove / move - :param new_pos: the new QVector3D position of the robot (None if robot to be removed) - :param new_orientation: the new orientation of the robot (radians) - :param clear_redo: If True, indicates a new action instead of an action from the undo/redo list. - clears redo list if True - """ + def __get_curr_world_state(self) -> WorldState: world_state = WorldState() # copy over existing robots for the current team @@ -690,6 +735,27 @@ def __update_world_state( world_state, robot_id, pos[0], pos[1] ) + return world_state + + def __update_world_state( + self, + new_robot_id: int, + new_pos: Optional[QVector3D], + new_orientation: float, + clear_redo=True, + ) -> None: + """Send out a WorldState proto with the existing robots + If new position is provided, adds a robot with the given id at the given position + Else, removes the robot with the given id from the robot state + + :param new_robot_id: the id of the robot to add / remove / move + :param new_pos: the new QVector3D position of the robot (None if robot to be removed) + :param new_orientation: the new orientation of the robot (radians) + :param clear_redo: If True, indicates a new action instead of an action from the undo/redo list. + clears redo list if True + """ + world_state = self.__get_curr_world_state() + world_state = self.__update_with_new_position( world_state, new_robot_id, new_pos, new_orientation ) @@ -724,18 +790,14 @@ def __update_with_new_position( world_state, robot_id, new_pos, new_orientation ) - # saves the state to local and pre-sim dicts - self.pre_sim_robot_positions[robot_id] = (new_pos, new_orientation) - if not self.is_playing: - # update the local state to the converted position - self.local_robot_positions[robot_id] = ( - new_pos, - new_orientation, - ) + # saves the state to local dict + self.local_robot_positions[robot_id] = ( + new_pos, + new_orientation, + ) else: # remove an existing robot self.curr_robot_ids.remove(robot_id) - del self.pre_sim_robot_positions[robot_id] self.local_robot_positions[robot_id] = None world_state = self.__remove_robot_from_state(world_state, robot_id) @@ -767,12 +829,17 @@ def _update_robots_graphics(self) -> None: """Overrides the _update_robots_graphics method in the super class Adds local state robots to the friendly team cache before updating the robot graphics """ - for robot_id, pos in self.local_robot_positions.items(): - if pos is None and robot_id in self._cached_friendly_team: - # if removed in local state, remove from dict - del self._cached_friendly_team[robot_id] - elif pos is not None: - # override position using local pos - self._cached_friendly_team[robot_id] = (pos[0].x(), pos[0].y(), pos[1]) + if not self.is_playing: + for robot_id, pos in self.local_robot_positions.items(): + if pos is None and robot_id in self._cached_friendly_team: + # if removed in local state, remove from dict + del self._cached_friendly_team[robot_id] + elif pos is not None: + # override position using local pos + self._cached_friendly_team[robot_id] = ( + pos[0].x(), + pos[0].y(), + pos[1], + ) super()._update_robots_graphics() diff --git a/src/software/thunderscope/gl/layers/gl_world_layer.py b/src/software/thunderscope/gl/layers/gl_world_layer.py index aed25404fd..5cc3160238 100644 --- a/src/software/thunderscope/gl/layers/gl_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_world_layer.py @@ -200,13 +200,12 @@ def toggle_play_state(self) -> bool: :return: the current play state """ + self.is_playing = not self.is_playing simulator_state = SimulationState( - is_playing=not self.is_playing, simulation_speed=self.simulation_speed + is_playing=self.is_playing, simulation_speed=self.simulation_speed ) - self.is_playing = not self.is_playing self.simulator_io.send_proto(SimulationState, simulator_state) - return self.is_playing def set_simulation_speed(self, speed: float) -> None: From 80753ad0d7334f6b2a06e72cb67f5dcc1f9fd356 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Wed, 1 Jul 2026 01:25:51 -0700 Subject: [PATCH 12/19] fixed edge case for start of sim with no referee command and inverted coords. added check + inversion skip for yellow team --- .../gl/layers/gl_sandbox_world_layer.py | 174 +++++++++++------- .../thunderscope/gl/layers/gl_world_layer.py | 17 +- .../gl/toolbars/gl_field_toolbar.py | 43 ++++- 3 files changed, 153 insertions(+), 81 deletions(-) diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 9cf9d2d01f..486c81fbf0 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -1,6 +1,6 @@ import math from abc import ABC, abstractmethod -from typing import Optional, override +from typing import Callable, Tuple, Optional, override from dataclasses import dataclass from proto.import_all_protos import * from pyqtgraph.Qt.QtCore import * @@ -80,6 +80,67 @@ class LastRobotRemoveError(Exception): class GLSandboxWorldLayer(GLWorldLayer): """GLWorldLayer that adds functionality to add, remove, and change the state of the robots on the field""" + class SandboxWorldState: + """Wrapper around WorldState that automatically handles coordinate frame + inversion when adding robots by delegating to the parent layer's + _invert_position_if_defending_negative_half method. + """ + + def __init__( + self, + invert_fn: Callable[[QVector3D, float], Tuple[QVector3D, float]], + friendly_colour_yellow: bool, + ): + self._proto = WorldState() + self._invert_fn = invert_fn + self._friendly_colour_yellow = friendly_colour_yellow + + @property + def proto(self) -> WorldState: + """Returns the underlying WorldState proto""" + return self._proto + + def set_ball_state(self, ball_state: BallState) -> None: + """Sets the ball state in the world state + + :param ball_state: the ball state to set + """ + self._proto.ball_state.CopyFrom(ball_state) + + def add_robot(self, robot_id: int, pos: QVector3D, orientation: float) -> None: + """Adds a robot to the world state, automatically inverting the + position and orientation if the coordinate frame should be inverted + + :param robot_id: the id of the robot to add + :param pos: the position of the robot + :param orientation: the orientation of the robot (radians) + """ + converted_pos, converted_orientation = self._invert_fn( + QVector3D(pos.x(), pos.y(), 0), orientation + ) + + robot_state = RobotState( + global_position=Point( + x_meters=converted_pos.x(), + y_meters=converted_pos.y(), + ), + global_orientation=Angle(radians=converted_orientation), + ) + if self._friendly_colour_yellow: + self._proto.yellow_robots[robot_id].CopyFrom(robot_state) + else: + self._proto.blue_robots[robot_id].CopyFrom(robot_state) + + def remove_robot(self, robot_id: int) -> None: + """Removes a robot from the world state + + :param robot_id: the id of the robot to remove + """ + if self._friendly_colour_yellow: + del self._proto.yellow_robots[robot_id] + else: + del self._proto.blue_robots[robot_id] + undo_toggle_enabled_signal = pyqtSignal(bool) redo_toggle_enabled_signal = pyqtSignal(bool) @@ -132,6 +193,8 @@ def __init__( self.sandbox_enabled = False + self._referee_defined = False + @override def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: """Requires Ctrl + Shift to be pressed along with mouse click @@ -261,6 +324,13 @@ def mouse_in_scene_released(self, event: MouseInSceneEvent) -> None: self.selected_robot_plane = None self.move_in_progress = False + @override + def _get_cached_teams_from_proto(self) -> None: + super()._get_cached_teams_from_proto() + + self.__check_referee_status() + + @override def refresh_graphics(self) -> None: """Calls the super class refresh graphics @@ -280,6 +350,15 @@ def refresh_graphics(self) -> None: self.next_id = len(self.curr_robot_ids) self.should_init_curr_robot_ids = False + def __check_referee_status(self) -> None: + """Checks if a referee message has been received. + Once a referee message is received and has content, sets a flag + permanently to True indicating we know which half we are defending. + """ + referee = self.referee_buffer.get(block=False) + if referee and referee.IsInitialized(): + self._referee_defined = True + def undo(self) -> None: """Undoes the last operation Adds a corresponding opposite move to the redo list so we can redo if necessary @@ -386,7 +465,7 @@ def clear_field(self) -> None: # send out empty world state world_state = self.__get_empty_world_state() - self.simulator_io.send_proto(WorldState, world_state) + self.simulator_io.send_proto(WorldState, world_state.proto) # robots are normally auto-rendered by refresh fn when sim is unpaused # when sim is paused, have to manually render @@ -446,24 +525,26 @@ def __undo_redo_internal(self, operation: Operation) -> None: ) # send out world state - self.simulator_io.send_proto(WorldState, world_state) + self.simulator_io.send_proto(WorldState, world_state.proto) else: self.next_id = operation.next_id self.__update_world_state( operation.id, operation.pos, self.DEFAULT_ROBOT_ANGLE, clear_redo=False ) - def __get_empty_world_state(self) -> WorldState: - """Constructs a WorldState with just the ball state filled in + def __get_empty_world_state(self) -> SandboxWorldState: + """Constructs a SandboxWorldState with just the ball state filled in from the cached world state and 1 robot placed at the edge of the center circle on the half line Replaces all local states as well - :return: the base world state with ball state and 1 robot + :return: the sandbox world state with ball state and 1 robot """ - world_state = WorldState() - world_state.ball_state.CopyFrom(self.cached_world.ball.current_state) + world_state = GLSandboxWorldLayer.SandboxWorldState( + self.__invert_robot_if_defending_negative_half, self.friendly_colour_yellow + ) + world_state.set_ball_state(self.cached_world.ball.current_state) center_circle_radius = self.cached_world.field.center_circle_radius robot_pos = QVector3D(-center_circle_radius, 0, 0) @@ -594,51 +675,6 @@ def __toggle_robot_remove_double_click(self) -> None: if self.robot_remove_double_click: self.robot_remove_double_click = None - def __add_robot_to_state( - self, world_state: WorldState, id: int, pos: QVector3D, orientation: float - ) -> WorldState: - """Adds a robot with the given state and id to the given world state - To the right team based on current team color - Converts position and orientation if needed - - :param world_state: the world state to add robot to - :param id: the id of the robot to add - :param pos: the new QVector3D position of the robot - :param orientation: the new orientation of the robot (radians) - """ - # convert position and orientation if needed - ( - converted_pos, - converted_orientation, - ) = self.__invert_robot_if_defending_negative_half(pos, orientation) - # build the robot state - robot_state = RobotState( - global_position=Point( - x_meters=converted_pos.x(), - y_meters=converted_pos.y(), - ), - global_orientation=Angle(radians=converted_orientation), - ) - - if self.friendly_colour_yellow: - world_state.yellow_robots[id].CopyFrom(robot_state) - else: - world_state.blue_robots[id].CopyFrom(robot_state) - return world_state - - def __remove_robot_from_state(self, world_state: WorldState, id: int) -> WorldState: - """Removes a robot with the given id from the right team in the given world state - Based on current team color, then shifts down all higher robot IDs to fill the gap - - :param world_state: the world state to remove robot from - :param id: the id of the robot to remove - """ - if self.friendly_colour_yellow: - del world_state.yellow_robots[id] - else: - del world_state.blue_robots[id] - return world_state - def __identify_robot( self, multi_plane_points: list[QVector3D] ) -> tuple[Optional[int], Optional[int]]: @@ -708,13 +744,14 @@ def __identify_robot_helper( return index return None - def __get_curr_world_state(self) -> WorldState: - world_state = WorldState() + def __get_curr_world_state(self) -> SandboxWorldState: + world_state = GLSandboxWorldLayer.SandboxWorldState( + self.__invert_robot_if_defending_negative_half, self.friendly_colour_yellow + ) # copy over existing robots for the current team for robot_ in self.cached_world.friendly_team.team_robots: - world_state = self.__add_robot_to_state( - world_state, + world_state.add_robot( robot_.id, QVector3D( robot_.current_state.global_position.x_meters, @@ -731,9 +768,7 @@ def __get_curr_world_state(self) -> WorldState: if pos is None: continue - world_state = self.__add_robot_to_state( - world_state, robot_id, pos[0], pos[1] - ) + world_state.add_robot(robot_id, pos[0], pos[1]) return world_state @@ -766,15 +801,15 @@ def __update_world_state( self.redo_operations.clear() # send out world state - self.simulator_io.send_proto(WorldState, world_state) + self.simulator_io.send_proto(WorldState, world_state.proto) def __update_with_new_position( self, - world_state: WorldState, + world_state: SandboxWorldState, robot_id: int, new_pos: Optional[QVector3D], new_orientation: float = 0, - ) -> WorldState: + ) -> SandboxWorldState: """Updates the world state with the new robot position for the given id New position is defined if adding / moving a robot and None if removing one @@ -786,9 +821,7 @@ def __update_with_new_position( """ if new_pos: self.curr_robot_ids.add(robot_id) - world_state = self.__add_robot_to_state( - world_state, robot_id, new_pos, new_orientation - ) + world_state.add_robot(robot_id, new_pos, new_orientation) # saves the state to local dict self.local_robot_positions[robot_id] = ( @@ -799,10 +832,17 @@ def __update_with_new_position( # remove an existing robot self.curr_robot_ids.remove(robot_id) self.local_robot_positions[robot_id] = None - world_state = self.__remove_robot_from_state(world_state, robot_id) + world_state.remove_robot(robot_id) return world_state + @override + def _should_invert_coordinate_frame(self) -> bool: + if not self._referee_defined: + return False + + return super()._should_invert_coordinate_frame() + def __invert_robot_if_defending_negative_half( self, point: QVector3D, orientation: float ) -> tuple[QVector3D, float]: diff --git a/src/software/thunderscope/gl/layers/gl_world_layer.py b/src/software/thunderscope/gl/layers/gl_world_layer.py index 5cc3160238..d17eaf7187 100644 --- a/src/software/thunderscope/gl/layers/gl_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_world_layer.py @@ -313,19 +313,13 @@ def mouse_in_scene_released(self, event: MouseInSceneEvent) -> None: self.ball_velocity_vector = None self.simulator_io.send_proto(WorldState, world_state) - @override - def refresh_graphics(self) -> None: - """Update graphics in this layer""" + def _get_cached_teams_from_proto(self) -> None: self.cached_world = self.world_buffer.get(block=False, return_cached=True) # if not receiving worlds, just render an empty field if is_field_message_empty(self.cached_world.field): self.cached_world = DEFAULT_EMPTY_FIELD_WORLD - self.__update_field_graphics(self.cached_world.field) - self.__update_goal_graphics(self.cached_world.field) - self.__update_ball_graphics(self.cached_world.ball.current_state) - self._cached_friendly_team = { robot.id: ( robot.current_state.global_position.x_meters, @@ -344,6 +338,15 @@ def refresh_graphics(self) -> None: for robot in self.cached_world.enemy_team.team_robots } + @override + def refresh_graphics(self) -> None: + """Update graphics in this layer""" + self._get_cached_teams_from_proto() + + self.__update_field_graphics(self.cached_world.field) + self.__update_goal_graphics(self.cached_world.field) + self.__update_ball_graphics(self.cached_world.ball.current_state) + self._update_robots_graphics() self.__update_robot_status_graphics() diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index 1c9ee8b97a..d4b7384b5b 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -139,17 +139,20 @@ def __init__( sidebar_button_layout.setContentsMargins(0, 0, 0, 0) sidebar_button_layout.setSpacing(4) - # button to show sidebar - self.sandbox_sidebar_button = StyledButton() - self.sandbox_sidebar_button.setText("Sandbox Mode") - self.sandbox_sidebar_button.setToolTip("Toggle Sandbox Sidebar") - self.sandbox_sidebar_button.clicked.connect(self.toggle_sidebar_visibility) + # sandbox mode title + sidebar_button_layout.addWidget(QLabel("Sandbox Mode")) # label to indicate sandbox mode state self.sandbox_sidebar_label = QLabel() - - sidebar_button_layout.addWidget(self.sandbox_sidebar_button) sidebar_button_layout.addWidget(self.sandbox_sidebar_label) + + # button to show sidebar + self.sidebar_open_button = StyledButton() + self.sidebar_open_button.setToolTip("Toggle Sandbox Sidebar") + self.sidebar_open_button.clicked.connect(self.toggle_sidebar_visibility) + self.__update_sidebar_open_button() + sidebar_button_layout.addWidget(self.sidebar_open_button) + self.sidebar_button_container.setLayout(sidebar_button_layout) # turn off sandbox mode as default @@ -205,10 +208,36 @@ def set_sidebar_visibility_callback( self.sidebar_visibility_callback = callback def toggle_sidebar_visibility(self) -> None: + """Toggles the sandbox sidebar visibility between shown and hidden + + Flips the internal visibility state, updates the sidebar open button + icon to reflect the new state, and invokes the sidebar visibility + callback if one has been set. + """ self.sandbox_sidebar_visible = not self.sandbox_sidebar_visible + + self.__update_sidebar_open_button() + if self.sidebar_visibility_callback: self.sidebar_visibility_callback(self.sandbox_sidebar_visible) + def __update_sidebar_open_button(self) -> None: + """Updates the sidebar open button icon to reflect current visibility + + Shows an up icon when the sidebar is visible, and a + down icon when it is hidden. + """ + self.sidebar_open_button.setIcon( + qta.icon( + ( + "fa6s.chevron-up" + if self.sandbox_sidebar_visible + else "fa6s.chevron-down" + ), + color=self.BUTTON_ICON_COLOR, + ) + ) + def set_sandbox_mode_enabled(self, enabled: bool) -> None: """Sets the sandbox enabled state and updates the label From 875c4bb4ea4cd48e84bafde80d827743d0399f9e Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Wed, 1 Jul 2026 01:27:19 -0700 Subject: [PATCH 13/19] format --- src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 486c81fbf0..85a871fc0e 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -330,7 +330,6 @@ def _get_cached_teams_from_proto(self) -> None: self.__check_referee_status() - @override def refresh_graphics(self) -> None: """Calls the super class refresh graphics @@ -840,7 +839,7 @@ def __update_with_new_position( def _should_invert_coordinate_frame(self) -> bool: if not self._referee_defined: return False - + return super()._should_invert_coordinate_frame() def __invert_robot_if_defending_negative_half( From f204066e56bcf9243340719ff53336acdbb64c05 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Wed, 1 Jul 2026 01:32:01 -0700 Subject: [PATCH 14/19] format --- .../thunderscope/gl/layers/gl_world_layer.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/software/thunderscope/gl/layers/gl_world_layer.py b/src/software/thunderscope/gl/layers/gl_world_layer.py index d17eaf7187..aed25404fd 100644 --- a/src/software/thunderscope/gl/layers/gl_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_world_layer.py @@ -200,12 +200,13 @@ def toggle_play_state(self) -> bool: :return: the current play state """ - self.is_playing = not self.is_playing simulator_state = SimulationState( - is_playing=self.is_playing, simulation_speed=self.simulation_speed + is_playing=not self.is_playing, simulation_speed=self.simulation_speed ) + self.is_playing = not self.is_playing self.simulator_io.send_proto(SimulationState, simulator_state) + return self.is_playing def set_simulation_speed(self, speed: float) -> None: @@ -313,13 +314,19 @@ def mouse_in_scene_released(self, event: MouseInSceneEvent) -> None: self.ball_velocity_vector = None self.simulator_io.send_proto(WorldState, world_state) - def _get_cached_teams_from_proto(self) -> None: + @override + def refresh_graphics(self) -> None: + """Update graphics in this layer""" self.cached_world = self.world_buffer.get(block=False, return_cached=True) # if not receiving worlds, just render an empty field if is_field_message_empty(self.cached_world.field): self.cached_world = DEFAULT_EMPTY_FIELD_WORLD + self.__update_field_graphics(self.cached_world.field) + self.__update_goal_graphics(self.cached_world.field) + self.__update_ball_graphics(self.cached_world.ball.current_state) + self._cached_friendly_team = { robot.id: ( robot.current_state.global_position.x_meters, @@ -338,15 +345,6 @@ def _get_cached_teams_from_proto(self) -> None: for robot in self.cached_world.enemy_team.team_robots } - @override - def refresh_graphics(self) -> None: - """Update graphics in this layer""" - self._get_cached_teams_from_proto() - - self.__update_field_graphics(self.cached_world.field) - self.__update_goal_graphics(self.cached_world.field) - self.__update_ball_graphics(self.cached_world.ball.current_state) - self._update_robots_graphics() self.__update_robot_status_graphics() From 45ed6c69255f12816f13374dfb433699d41095b1 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Wed, 1 Jul 2026 09:56:31 -0700 Subject: [PATCH 15/19] fix test --- .../sensor_fusion/sensor_fusion_test.cpp | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/software/sensor_fusion/sensor_fusion_test.cpp b/src/software/sensor_fusion/sensor_fusion_test.cpp index 5d40f74d71..6f9319e49a 100644 --- a/src/software/sensor_fusion/sensor_fusion_test.cpp +++ b/src/software/sensor_fusion/sensor_fusion_test.cpp @@ -174,7 +174,9 @@ class SensorFusionTest : public ::testing::Test friendly_robots.emplace_back(state.id, state.robot_state, current_time); } friendly_team.updateRobots(friendly_robots); - friendly_team.assignGoalie(0); + // This must be a robot ID that actually exists on the team, since + // SensorFusion::resolveGoalieId validates the goalie ID exists before assigning it + friendly_team.assignGoalie(1); Team enemy_team; std::vector enemy_robots; for (const auto& state : initBlueRobotStates()) @@ -182,7 +184,9 @@ class SensorFusionTest : public ::testing::Test enemy_robots.emplace_back(state.id, state.robot_state, current_time); } enemy_team.updateRobots(enemy_robots); - enemy_team.assignGoalie(0); + // This must be a robot ID that actually exists on the team, since + // SensorFusion::resolveGoalieId validates the goalie ID exists before assigning it + enemy_team.assignGoalie(1); return World(field, ball, friendly_team, enemy_team); } @@ -197,7 +201,9 @@ class SensorFusionTest : public ::testing::Test friendly_robots.emplace_back(state.id, state.robot_state, current_time); } friendly_team.updateRobots(friendly_robots); - friendly_team.assignGoalie(0); + // This must be a robot ID that actually exists on the team, since + // SensorFusion::resolveGoalieId validates the goalie ID before assigning it + friendly_team.assignGoalie(1); Team enemy_team; std::vector enemy_robots; for (const auto& state : initInvertedBlueRobotStates()) @@ -205,7 +211,9 @@ class SensorFusionTest : public ::testing::Test enemy_robots.emplace_back(state.id, state.robot_state, current_time); } enemy_team.updateRobots(enemy_robots); - enemy_team.assignGoalie(0); + // This must be a robot ID that actually exists on the team, since + // SensorFusion::resolveGoalieId validates the goalie ID before assigning it + enemy_team.assignGoalie(1); return World(field, ball, friendly_team, enemy_team); } @@ -787,8 +795,9 @@ TEST_F(SensorFusionTest, test_sensor_fusion_reset_behaviour_trigger_reset) { config.set_override_game_controller_friendly_goalie_id(true); config.set_override_game_controller_enemy_goalie_id(true); - config.set_friendly_goalie_id(0); - config.set_enemy_goalie_id(0); + // Must use a robot ID that exists on both teams (1 is the lowest ID in our test data) + config.set_friendly_goalie_id(1); + config.set_enemy_goalie_id(1); sensor_fusion = SensorFusion(config); SensorProto sensor_msg; From 37a935245d9bf370da47f8b28c2ed67038f5d0f5 Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Wed, 1 Jul 2026 10:15:29 -0700 Subject: [PATCH 16/19] added singleton local state to sync between fullsystems + common sandbox state proto to be used --- src/proto/world.proto | 5 + .../gl/layers/gl_sandbox_world_layer.py | 355 ++++++++++++------ .../gl/sandbox/local_robot_state_provider.py | 78 ++++ .../gl/toolbars/gl_field_toolbar.py | 3 + 4 files changed, 328 insertions(+), 113 deletions(-) create mode 100644 src/software/thunderscope/gl/sandbox/local_robot_state_provider.py diff --git a/src/proto/world.proto b/src/proto/world.proto index 287caacbab..b222b98589 100644 --- a/src/proto/world.proto +++ b/src/proto/world.proto @@ -61,6 +61,11 @@ message SimulationState required double simulation_speed = 2 [default = 1.0]; } +message SandboxModeState +{ + required bool is_enabled = 1 [default = false]; +} + message Pass { // The location of the passer diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index 9cf9d2d01f..9c38d4a44e 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -10,6 +10,9 @@ from software.thunderscope.gl.layers.gl_world_layer import GLWorldLayer from software.thunderscope.gl.helpers.extended_gl_view_widget import MouseInSceneEvent from software.thunderscope.proto_unix_io import ProtoUnixIO +from software.thunderscope.gl.sandbox.local_robot_state_provider import ( + local_robot_state_provider_instance, +) from software.thunderscope.constants import Colors, DepthValues @@ -17,10 +20,11 @@ class Operation(ABC): """Interface for operations that can be undone/redone""" @abstractmethod - def reverse(self, next_id: int) -> "Operation": + def reverse(self, friendly_next_id: int, enemy_next_id: int) -> "Operation": """Returns the reverse of this operation - :param next_id: the next robot id to use in the reversed operation + :param friendly_next_id: the next friendly robot id to use in the reversed operation + :param enemy_next_id: the next enemy robot id to use in the reversed operation :return: the reverse of this operation """ pass @@ -37,14 +41,16 @@ class RobotOperation(Operation): prev_pos: Optional[QVector3D] pos: Optional[QVector3D] next_id: int + is_friendly: bool @override - def reverse(self, next_id: int) -> Operation: + def reverse(self, friendly_next_id: int, enemy_next_id: int) -> Operation: return RobotOperation( id=self.id, prev_pos=self.pos, pos=self.prev_pos, - next_id=next_id, + next_id=friendly_next_id if self.is_friendly else enemy_next_id, + is_friendly=self.is_friendly, ) @@ -55,14 +61,15 @@ class GroupOperation(Operation): operations: list[RobotOperation] @override - def reverse(self, next_id: int) -> Operation: + def reverse(self, friendly_next_id: int, enemy_next_id: int) -> Operation: return GroupOperation( operations=[ RobotOperation( id=op.id, prev_pos=op.pos, pos=op.prev_pos, - next_id=next_id, + next_id=friendly_next_id if op.is_friendly else enemy_next_id, + is_friendly=op.is_friendly, ) for op in self.operations ] @@ -115,17 +122,18 @@ def __init__( self.move_in_progress = False # the currently added robots and the next id to add - self.next_id = 0 - self.curr_robot_ids = set() - # if the world has robots already, update curr_robot_ids on the first tick + self.friendly_next_id = 0 + self.enemy_next_id = 0 + + self.friendly_curr_robots: set[int] = set() + self.enemy_curr_robots: set[int] = set() + self.curr_robot_ids_map: dict[bool, set[int]] = { + True: self.friendly_curr_robots, + False: self.enemy_curr_robots, + } + # if the world has robots already, update curr robot ids on the first tick self.should_init_curr_robot_ids = True - # the local state of robots (if simulator is paused) - # map of robot id to a tuple with the robot coordinates and orientation - # or None if the robot has been removed already - # (easier to keep track of robots rather than removing the entry entirely) - self.local_robot_positions: dict[int, tuple[QVector3D, float]] = {} - # stacks for undo and redo operations self.undo_operations = [] self.redo_operations = [] @@ -153,20 +161,20 @@ def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: if not event.mouse_event.modifiers() & Qt.KeyboardModifier.ControlModifier: return - try: - # determine whether a robot was clicked - robot_id, index = self.__identify_robot(event.multi_plane_points) - except EnemyAtMousePositionError: - # if enemy robot is already at mouse position, return + # determine whether a robot was clicked + robot_id, index, is_friendly = self.__identify_robot(event.multi_plane_points) + + # if an enemy robot is at the mouse position, return + if robot_id is not None and not is_friendly: return if robot_id is None: # if no robot was clicked - self.__handle_new_robot_event(event) + self.__handle_new_robot_event(event, is_friendly) else: # if a robot was clicked try: - self.__handle_existing_robot_event(event, robot_id, index) + self.__handle_existing_robot_event(event, robot_id, index, is_friendly) except LastRobotRemoveError: # if the user attempted to remove the last robot # self.__display_last_remove_warning(event) @@ -204,11 +212,7 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: point_on_current_plane = event.multi_plane_points[self.selected_robot_plane] # check if the mouse position is free or not - try: - robot_id, _ = self.__identify_robot([point_on_current_plane]) - except EnemyAtMousePositionError: - # if enemy robot is already at mouse position, return - return + robot_id, _, is_friendly = self.__identify_robot([point_on_current_plane]) # skip if new position has a robot already if robot_id is not None: @@ -226,7 +230,8 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: self.selected_robot_id, point_on_current_plane, self.selected_robot_pos, - self.next_id, + self.friendly_next_id, + is_friendly, ) ) self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) @@ -235,7 +240,10 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: # update selected robot position self.__update_world_state( - self.selected_robot_id, point_on_current_plane, self.DEFAULT_ROBOT_ANGLE + self.selected_robot_id, + point_on_current_plane, + self.DEFAULT_ROBOT_ANGLE, + is_friendly=is_friendly, ) # robots are normally auto-rendered by refresh fn when sim is unpaused @@ -275,9 +283,9 @@ def refresh_graphics(self) -> None: if self.should_init_curr_robot_ids: # for robots in the world, add the ids them to curr robots for robot in self.cached_world.friendly_team.team_robots: - self.curr_robot_ids.add(robot.id) + self.friendly_curr_robots.add(robot.id) - self.next_id = len(self.curr_robot_ids) + self.friendly_next_id = len(self.friendly_curr_robots) self.should_init_curr_robot_ids = False def undo(self) -> None: @@ -292,7 +300,9 @@ def undo(self) -> None: operation = self.undo_operations.pop() # add the reverse operation to the redo list - self.redo_operations.append(operation.reverse(self.next_id)) + self.redo_operations.append( + operation.reverse(self.friendly_next_id, self.enemy_next_id) + ) # apply the operation self.__undo_redo_internal(operation) @@ -318,7 +328,9 @@ def redo(self) -> None: operation = self.redo_operations.pop() # add the reverse operation to the undo list - self.undo_operations.append(operation.reverse(self.next_id)) + self.undo_operations.append( + operation.reverse(self.friendly_next_id, self.enemy_next_id) + ) # apply the operation self.__undo_redo_internal(operation) @@ -353,11 +365,12 @@ def clear_field(self) -> None: robot_.current_state.global_position.y_meters, 0, ), - next_id=self.next_id, + next_id=self.friendly_next_id, + is_friendly=True, ) # then, update with local state - for robot_id, pos_and_orient in self.local_robot_positions.items(): + for robot_id, pos_and_orient in local_robot_state_provider_instance.get_team_state(self.friendly_colour_yellow).items(): # if the local robot has already been removed, skip it if pos_and_orient is None: continue @@ -368,7 +381,8 @@ def clear_field(self) -> None: id=robot_id, prev_pos=None, pos=position, - next_id=self.next_id, + next_id=self.friendly_next_id, + is_friendly=True, ) if operations: @@ -376,8 +390,10 @@ def clear_field(self) -> None: self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) # clear internal state - self.curr_robot_ids.clear() - self.local_robot_positions.clear() + self.friendly_curr_robots.clear() + self.enemy_curr_robots.clear() + local_robot_state_provider_instance.clear_team(self.friendly_colour_yellow) + local_robot_state_provider_instance.clear_team(not self.friendly_colour_yellow) # clear redo list since this is a new action self.redo_operations.clear() @@ -403,7 +419,8 @@ def toggle_play_state(self) -> bool: curr_play_state = super().toggle_play_state() # reset the local state - self.local_robot_positions = {} + local_robot_state_provider_instance.clear_team(self.friendly_colour_yellow) + local_robot_state_provider_instance.clear_team(not self.friendly_colour_yellow) return curr_play_state @@ -435,22 +452,41 @@ def __undo_redo_internal(self, operation: Operation) -> None: :param operation: the operation to apply """ if isinstance(operation, GroupOperation): - self.next_id = float("inf") + self.friendly_next_id = float("inf") + self.enemy_next_id = float("inf") world_state = self.__get_curr_world_state() for inner_op in operation.operations: - self.next_id = int(min(self.next_id, inner_op.next_id)) + if inner_op.is_friendly: + self.friendly_next_id = int( + min(self.friendly_next_id, inner_op.next_id) + ) + else: + self.enemy_next_id = int( + min(self.enemy_next_id, inner_op.next_id) + ) world_state = self.__update_with_new_position( - world_state, inner_op.id, inner_op.pos, self.DEFAULT_ROBOT_ANGLE + world_state, + inner_op.id, + inner_op.pos, + self.DEFAULT_ROBOT_ANGLE, + inner_op.is_friendly, ) # send out world state self.simulator_io.send_proto(WorldState, world_state) else: - self.next_id = operation.next_id + if operation.is_friendly: + self.friendly_next_id = operation.next_id + else: + self.enemy_next_id = operation.next_id self.__update_world_state( - operation.id, operation.pos, self.DEFAULT_ROBOT_ANGLE, clear_redo=False + operation.id, + operation.pos, + self.DEFAULT_ROBOT_ANGLE, + clear_redo=False, + is_friendly=operation.is_friendly, ) def __get_empty_world_state(self) -> WorldState: @@ -468,14 +504,20 @@ def __get_empty_world_state(self) -> WorldState: center_circle_radius = self.cached_world.field.center_circle_radius robot_pos = QVector3D(-center_circle_radius, 0, 0) - for robot_id in self.local_robot_positions.keys(): - self.local_robot_positions[robot_id] = None + for robot_id in local_robot_state_provider_instance.get_team_state(self.friendly_colour_yellow).keys(): + local_robot_state_provider_instance.remove_robot(robot_id, self.friendly_colour_yellow) + for robot_id in local_robot_state_provider_instance.get_team_state(not self.friendly_colour_yellow).keys(): + local_robot_state_provider_instance.remove_robot(robot_id, not self.friendly_colour_yellow) world_state = self.__update_with_new_position( - world_state, self.MIN_ROBOT_ID, robot_pos, self.DEFAULT_ROBOT_ANGLE + world_state, + self.MIN_ROBOT_ID, + robot_pos, + self.DEFAULT_ROBOT_ANGLE, + is_friendly=True, ) - self.next_id = self.MIN_ROBOT_ID + 1 + self.friendly_next_id = self.MIN_ROBOT_ID + 1 return world_state @@ -484,7 +526,11 @@ def __get_empty_world_state(self) -> WorldState: # # # # # # # # # # # # # # # # # # # # # # # # # def __handle_existing_robot_event( - self, event: MouseInSceneEvent, robot_id: int, index: int + self, + event: MouseInSceneEvent, + robot_id: int, + index: int, + is_friendly: bool, ) -> None: """Handles a mouse event when a position where a robot is present is clicked Marks the robot as selected (for drag moving) @@ -494,6 +540,7 @@ def __handle_existing_robot_event( :param event: the event containing the xy-plane and other plane coordinates :param robot_id: the id of the robot that was clicked on :param index: the plane index that the robot was selected on + :param is_friendly: whether the robot is on the friendly team """ # marks the robot as selected along with the plane index that the mouse click intersected with # and its current position on that plane @@ -507,7 +554,7 @@ def __handle_existing_robot_event( and self.robot_remove_double_click == event.multi_plane_points[index] ): # prevent removing the last robot - if len(self.curr_robot_ids) <= 1: + if len(self.curr_robot_ids_map[is_friendly]) <= 1: self.__toggle_robot_remove_double_click() raise LastRobotRemoveError("Trying to remove the final robot!") @@ -517,13 +564,22 @@ def __handle_existing_robot_event( robot_id, None, event.multi_plane_points[index], - self.next_id, + self.friendly_next_id if is_friendly else self.enemy_next_id, + is_friendly, ) ) # remove the robot - self.__update_world_state(robot_id, None, self.DEFAULT_ROBOT_ANGLE) + self.__update_world_state( + robot_id, + None, + self.DEFAULT_ROBOT_ANGLE, + is_friendly=is_friendly, + ) # set next id to the lowest free id - self.next_id = min(self.next_id, robot_id) + if is_friendly: + self.friendly_next_id = min(self.friendly_next_id, robot_id) + else: + self.enemy_next_id = min(self.enemy_next_id, robot_id) self.__toggle_robot_remove_double_click() else: # start a remove double click @@ -542,44 +598,70 @@ def __display_last_remove_warning(self, event: MouseInSceneEvent) -> None: ], ) - def __handle_new_robot_event(self, event: MouseInSceneEvent) -> None: + def __handle_new_robot_event( + self, event: MouseInSceneEvent, is_friendly: bool + ) -> None: """Handles a mouse event when an empty position is clicked If double clicked, adds a new robot at that position Else, starts a double click :param event: the mouse event with the new robot's position + :param is_friendly: whether the robot is on the friendly team """ # if the current point is a double click in progress if ( self.robot_add_double_click and self.robot_add_double_click == event.point_in_scene ): + curr_next_id = ( + self.friendly_next_id if is_friendly else self.enemy_next_id + ) + # add an undo operation to remove the robot that is being added self.__add_undo_operation( - RobotOperation(self.next_id, event.point_in_scene, None, self.next_id) + RobotOperation( + curr_next_id, + event.point_in_scene, + None, + curr_next_id, + is_friendly, + ) ) # add the robot self.__update_world_state( - self.next_id, event.point_in_scene, self.DEFAULT_ROBOT_ANGLE + curr_next_id, + event.point_in_scene, + self.DEFAULT_ROBOT_ANGLE, + is_friendly=is_friendly, + ) + + new_next_id = self.__get_next_robot_id( + curr_next_id, self.curr_robot_ids_map[is_friendly] ) - self.next_id = self.__get_next_robot_id(self.next_id) + + if is_friendly: + self.friendly_next_id = new_next_id + else: + self.enemy_next_id = new_next_id + self.__toggle_robot_add_double_click() else: # start a double click self.robot_add_double_click = event.point_in_scene QTimer.singleShot(500, self.__toggle_robot_add_double_click) - def __get_next_robot_id(self, curr_next_id: int) -> int: + def __get_next_robot_id(self, curr_next_id: int, curr_robot_ids: set[int]) -> int: """Gets the id of the next robot to add based on the currently added robot ids :param curr_next_id: the current next id to add + :param curr_robot_ids: the set of currently used robot ids for this team """ # start with the default next id next_id = curr_next_id + 1 # loops until a free id is found - while next_id in self.curr_robot_ids: + while next_id in curr_robot_ids: next_id += 1 return next_id @@ -595,16 +677,22 @@ def __toggle_robot_remove_double_click(self) -> None: self.robot_remove_double_click = None def __add_robot_to_state( - self, world_state: WorldState, id: int, pos: QVector3D, orientation: float + self, + world_state: WorldState, + id: int, + pos: QVector3D, + orientation: float, + is_friendly: bool, ) -> WorldState: """Adds a robot with the given state and id to the given world state - To the right team based on current team color + To the right team based on current team color and is_friendly flag Converts position and orientation if needed :param world_state: the world state to add robot to :param id: the id of the robot to add :param pos: the new QVector3D position of the robot :param orientation: the new orientation of the robot (radians) + :param is_friendly: whether the robot is on the friendly team """ # convert position and orientation if needed ( @@ -620,7 +708,7 @@ def __add_robot_to_state( global_orientation=Angle(radians=converted_orientation), ) - if self.friendly_colour_yellow: + if self.friendly_colour_yellow == is_friendly: world_state.yellow_robots[id].CopyFrom(robot_state) else: world_state.blue_robots[id].CopyFrom(robot_state) @@ -639,57 +727,77 @@ def __remove_robot_from_state(self, world_state: WorldState, id: int) -> WorldSt del world_state.blue_robots[id] return world_state - def __identify_robot( - self, multi_plane_points: list[QVector3D] + def __identify_robot_with_locals( + self, + multi_plane_points: list[QVector3D], + team, + local_state_map: dict[int, tuple[QVector3D, float] | None], ) -> tuple[Optional[int], Optional[int]]: - """Identify which robot was clicked on the team + """Check local state first, then team robots, for a robot at the given position - :param multi_plane_points: points on the x-y plane and planes above it corresponding to the mouse click - :return: The robot if one is present at the mouse position, - along with the index of the plane it was identified on, - else None, None + :param multi_plane_points: points on the x-y plane and planes above it + :param team: the team to check (friendly or enemy) + :param local_state_map: local robot position map (id -> (QVector3D, float) or None) + :return: the robot id and plane index if found, else None, None """ - # first, check if there's any enemy robots being clicked on - # return None immediately if so (since we can't edit enemy robot state) - for robot_ in self.cached_world.enemy_team.team_robots: - # get the coordinates from the robot state - pos_x = robot_.current_state.global_position.x_meters - pos_y = robot_.current_state.global_position.y_meters - - # check if robot in position and return if found - index = self.__identify_robot_helper(multi_plane_points, pos_x, pos_y) - - if index is not None: - raise EnemyAtMousePositionError("Enemy robot at this position already!") - - # look for robot in local state - for robot_id, pos in self.local_robot_positions.items(): - # if the local robot has already been removed, skip it + # check local state first + for robot_id, pos in local_state_map.items(): if pos is None: continue - # check if robot in position and return if found - index = self.__identify_robot_helper( + index = self.__identify_robot_with_pos( multi_plane_points, pos[0].x(), pos[0].y() ) if index is not None: return robot_id, index - # if not found, look for robot in cached world state - for robot_ in self.cached_world.friendly_team.team_robots: - # get the coordinates from the robot state + # then check team robots + for robot_ in team.team_robots: pos_x = robot_.current_state.global_position.x_meters pos_y = robot_.current_state.global_position.y_meters - # check if robot in position and return if found - index = self.__identify_robot_helper(multi_plane_points, pos_x, pos_y) + index = self.__identify_robot_with_pos(multi_plane_points, pos_x, pos_y) if index is not None: return robot_.id, index + return None, None - def __identify_robot_helper( + def __identify_robot( + self, multi_plane_points: list[QVector3D] + ) -> tuple[Optional[int], Optional[int], bool]: + """Identify which robot was clicked on the field + + :param multi_plane_points: points on the x-y plane and planes above it corresponding to the mouse click + :return: The robot id if one is present at the mouse position, + along with the index of the plane it was identified on, + and whether the robot is friendly (True) or enemy (False), + else None, None, False + """ + # check friendly team first (with local state) + robot_id, index = self.__identify_robot_with_locals( + multi_plane_points, + self.cached_world.friendly_team, + self.friendly_local_robot_positions, + ) + + if robot_id is not None: + return robot_id, index, True + + # check enemy team (with local state) + robot_id, index = self.__identify_robot_with_locals( + multi_plane_points, + self.cached_world.enemy_team, + self.enemy_local_robot_positions, + ) + + if robot_id is not None: + return robot_id, index, False + + return None, None, False + + def __identify_robot_with_pos( self, multi_plane_points, pos_x, pos_y ) -> Optional[int]: """Loops over the multi plane points given and checks if any of them are within the radius of the given robot @@ -722,17 +830,18 @@ def __get_curr_world_state(self) -> WorldState: 0, ), robot_.current_state.global_orientation.radians, + is_friendly=True, ) # copy over any local state robots if sim is paused if not self.is_playing: - for robot_id, pos in self.local_robot_positions.items(): + for robot_id, pos in self.friendly_local_robot_positions.items(): # if the robot has already been removed, skip it if pos is None: continue world_state = self.__add_robot_to_state( - world_state, robot_id, pos[0], pos[1] + world_state, robot_id, pos[0], pos[1], is_friendly=True ) return world_state @@ -743,6 +852,7 @@ def __update_world_state( new_pos: Optional[QVector3D], new_orientation: float, clear_redo=True, + is_friendly: bool = True, ) -> None: """Send out a WorldState proto with the existing robots If new position is provided, adds a robot with the given id at the given position @@ -753,11 +863,12 @@ def __update_world_state( :param new_orientation: the new orientation of the robot (radians) :param clear_redo: If True, indicates a new action instead of an action from the undo/redo list. clears redo list if True + :param is_friendly: whether the robot is on the friendly team """ world_state = self.__get_curr_world_state() world_state = self.__update_with_new_position( - world_state, new_robot_id, new_pos, new_orientation + world_state, new_robot_id, new_pos, new_orientation, is_friendly ) # if we've just done a new action (not undone an old action) @@ -774,6 +885,7 @@ def __update_with_new_position( robot_id: int, new_pos: Optional[QVector3D], new_orientation: float = 0, + is_friendly: bool = True, ) -> WorldState: """Updates the world state with the new robot position for the given id New position is defined if adding / moving a robot and None if removing one @@ -782,23 +894,25 @@ def __update_with_new_position( :param robot_id: the id of thr robot to add / move / remove :param new_pos: the new position of the robot, or None if removing :param new_orientation: the new orientation of the robot if needed (radians) + :param is_friendly: whether the robot is on the friendly team :return: the updated world state """ if new_pos: - self.curr_robot_ids.add(robot_id) + self.curr_robot_ids_map[is_friendly].add(robot_id) + world_state = self.__add_robot_to_state( - world_state, robot_id, new_pos, new_orientation + world_state, robot_id, new_pos, new_orientation, is_friendly ) # saves the state to local dict - self.local_robot_positions[robot_id] = ( + self.local_robot_positions_map[is_friendly][robot_id] = ( new_pos, new_orientation, ) else: # remove an existing robot - self.curr_robot_ids.remove(robot_id) - self.local_robot_positions[robot_id] = None + self.curr_robot_ids_map[is_friendly].remove(robot_id) + self.local_robot_positions_map[is_friendly][robot_id] = None world_state = self.__remove_robot_from_state(world_state, robot_id) return world_state @@ -824,22 +938,37 @@ def __invert_robot_if_defending_negative_half( # GRAPHICS UPDATE METHODS # # # # # # # # # # # # # # # # # # # # # + def __override_cache_with_locals( + self, + local_positions: dict[int, tuple[QVector3D, float] | None], + cached_team: dict[int, tuple[float, float, float]], + ) -> None: + """Override a cached team dict with local robot positions + + :param local_positions: map of robot id to (position, orientation) or None if removed + :param cached_team: the cached team dict to override (e.g. self._cached_friendly_team) + """ + for robot_id, pos in local_positions.items(): + if pos is None and robot_id in cached_team: + del cached_team[robot_id] + elif pos is not None: + cached_team[robot_id] = ( + pos[0].x(), + pos[0].y(), + pos[1], + ) + @override def _update_robots_graphics(self) -> None: """Overrides the _update_robots_graphics method in the super class - Adds local state robots to the friendly team cache before updating the robot graphics + Adds local state robots to the team caches before updating the robot graphics """ if not self.is_playing: - for robot_id, pos in self.local_robot_positions.items(): - if pos is None and robot_id in self._cached_friendly_team: - # if removed in local state, remove from dict - del self._cached_friendly_team[robot_id] - elif pos is not None: - # override position using local pos - self._cached_friendly_team[robot_id] = ( - pos[0].x(), - pos[0].y(), - pos[1], - ) + self.__override_cache_with_locals( + self.friendly_local_robot_positions, self._cached_friendly_team + ) + self.__override_cache_with_locals( + self.enemy_local_robot_positions, self._cached_enemy_team + ) super()._update_robots_graphics() diff --git a/src/software/thunderscope/gl/sandbox/local_robot_state_provider.py b/src/software/thunderscope/gl/sandbox/local_robot_state_provider.py new file mode 100644 index 0000000000..3512f10fad --- /dev/null +++ b/src/software/thunderscope/gl/sandbox/local_robot_state_provider.py @@ -0,0 +1,78 @@ +from pyqtgraph.Qt.QtGui import QVector3D + + +class LocalRobotStateProvider: + """A singleton class that provides local robot state management + + Used to maintain the state of robots when the simulator is paused, + so sandbox edits (add, remove, move robots) persist while paused. + """ + + def __init__(self) -> None: + """Constructs a LocalRobotStateProvider with empty state""" + # map of robot id to a tuple with the robot coordinates and orientation + # or None if the robot has been removed already + # (easier to keep track of robots rather than removing the entry entirely) + self.yellow_robot_states: dict[int, tuple[QVector3D, float] | None] = {} + self.blue_robot_states: dict[int, tuple[QVector3D, float] | None] = {} + + def update_robot( + self, + robot_id: int, + position: QVector3D, + orientation: float, + is_yellow: bool, + ) -> None: + """Add or update a robot's local state + + :param robot_id: the id of the robot + :param position: the new position of the robot + :param orientation: the new orientation of the robot (radians) + :param is_yellow: whether the robot is on the yellow team + """ + if is_yellow: + self.yellow_robot_states[robot_id] = (position, orientation) + else: + self.blue_robot_states[robot_id] = (position, orientation) + + def remove_robot(self, robot_id: int, is_yellow: bool) -> None: + """Mark a robot as removed in local state + + :param robot_id: the id of the robot to remove + :param is_yellow: whether the robot is on the yellow team + """ + if is_yellow: + self.yellow_robot_states[robot_id] = None + else: + self.blue_robot_states[robot_id] = None + + def get_team_state( + self, is_yellow: bool + ) -> dict[int, tuple[QVector3D, float] | None]: + """Get the full local state dict for a team + + :param is_yellow: whether to return the yellow team state + :return: the dict of robot id to (position, orientation) or None + """ + if is_yellow: + return self.yellow_robot_states + else: + return self.blue_robot_states + + def clear_team(self, is_yellow: bool) -> None: + """Clear all local state for a team + + :param is_yellow: whether to clear the yellow team state + """ + if is_yellow: + self.yellow_robot_states.clear() + else: + self.blue_robot_states.clear() + + def clear_all(self) -> None: + """Clear all local robot state""" + self.yellow_robot_states.clear() + self.blue_robot_states.clear() + + +local_robot_state_provider_instance = LocalRobotStateProvider() diff --git a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py index 1c9ee8b97a..f69ede741f 100644 --- a/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py +++ b/src/software/thunderscope/gl/toolbars/gl_field_toolbar.py @@ -134,6 +134,9 @@ def __init__( self.sandbox_sidebar_visible = False self.sidebar_visibility_callback = None + # buffer for the sandbox mode enabled state + self.simulation_state_buffer = ThreadSafeBuffer(5, SimulationState) + self.sidebar_button_container = QWidget() sidebar_button_layout = QHBoxLayout() sidebar_button_layout.setContentsMargins(0, 0, 0, 0) From 26eceaa8ddf1c7f5fbabc207e4906d2e66dbc68d Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Thu, 2 Jul 2026 05:40:24 -0700 Subject: [PATCH 17/19] fixed synced local states + added repaint callback + fixed enemy ids + added UI to choose which team to add robots to --- src/proto/world.proto | 2 +- .../sensor_fusion/sensor_fusion_test.cpp | 6 +- src/software/thunderscope/constants.py | 6 + src/software/thunderscope/gl/layers/BUILD | 1 + .../gl/layers/gl_sandbox_world_layer.py | 180 ++++++++++-------- src/software/thunderscope/gl/sandbox/BUILD | 8 + .../gl/sandbox/gl_sandbox_sidebar.py | 44 ++++- .../gl/sandbox/local_robot_state_provider.py | 20 +- .../thunderscope/widget_setup_functions.py | 1 + 9 files changed, 179 insertions(+), 89 deletions(-) diff --git a/src/proto/world.proto b/src/proto/world.proto index b222b98589..f8a0dc1dcc 100644 --- a/src/proto/world.proto +++ b/src/proto/world.proto @@ -63,7 +63,7 @@ message SimulationState message SandboxModeState { - required bool is_enabled = 1 [default = false]; + required bool is_enabled = 1 [default = false]; } message Pass diff --git a/src/software/sensor_fusion/sensor_fusion_test.cpp b/src/software/sensor_fusion/sensor_fusion_test.cpp index 6f9319e49a..80ea873a95 100644 --- a/src/software/sensor_fusion/sensor_fusion_test.cpp +++ b/src/software/sensor_fusion/sensor_fusion_test.cpp @@ -175,7 +175,8 @@ class SensorFusionTest : public ::testing::Test } friendly_team.updateRobots(friendly_robots); // This must be a robot ID that actually exists on the team, since - // SensorFusion::resolveGoalieId validates the goalie ID exists before assigning it + // SensorFusion::resolveGoalieId validates the goalie ID exists before assigning + // it friendly_team.assignGoalie(1); Team enemy_team; std::vector enemy_robots; @@ -185,7 +186,8 @@ class SensorFusionTest : public ::testing::Test } enemy_team.updateRobots(enemy_robots); // This must be a robot ID that actually exists on the team, since - // SensorFusion::resolveGoalieId validates the goalie ID exists before assigning it + // SensorFusion::resolveGoalieId validates the goalie ID exists before assigning + // it enemy_team.assignGoalie(1); return World(field, ball, friendly_team, enemy_team); } diff --git a/src/software/thunderscope/constants.py b/src/software/thunderscope/constants.py index bda6e1ebf9..809858eeec 100644 --- a/src/software/thunderscope/constants.py +++ b/src/software/thunderscope/constants.py @@ -209,6 +209,12 @@ class EstopMode(IntEnum): """ ) + +class TeamToAdd(StrEnum): + BLUE = "Blue" + YELLOW = "Yellow" + + THUNDERSCOPE_UI_FONT_NAME = "Roboto" diff --git a/src/software/thunderscope/gl/layers/BUILD b/src/software/thunderscope/gl/layers/BUILD index eef31df0e2..ff6b8d5229 100644 --- a/src/software/thunderscope/gl/layers/BUILD +++ b/src/software/thunderscope/gl/layers/BUILD @@ -92,6 +92,7 @@ py_library( srcs = ["gl_sandbox_world_layer.py"], deps = [ ":gl_world_layer", + "//software/thunderscope/gl/sandbox:local_robot_state_provider", requirement("pyqtgraph"), ], ) diff --git a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py index fb7815e2af..73247e98b9 100644 --- a/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py +++ b/src/software/thunderscope/gl/layers/gl_sandbox_world_layer.py @@ -3,6 +3,7 @@ from typing import Callable, Tuple, Optional, override from dataclasses import dataclass from proto.import_all_protos import * +from proto.ssl_gc_common_pb2 import Team from pyqtgraph.Qt.QtCore import * from pyqtgraph.Qt.QtGui import * from pyqtgraph.opengl import * @@ -114,7 +115,9 @@ def set_ball_state(self, ball_state: BallState) -> None: """ self._proto.ball_state.CopyFrom(ball_state) - def add_robot(self, robot_id: int, pos: QVector3D, orientation: float) -> None: + def add_robot( + self, robot_id: int, pos: QVector3D, orientation: float, is_friendly: bool + ) -> None: """Adds a robot to the world state, automatically inverting the position and orientation if the coordinate frame should be inverted @@ -133,17 +136,17 @@ def add_robot(self, robot_id: int, pos: QVector3D, orientation: float) -> None: ), global_orientation=Angle(radians=converted_orientation), ) - if self._friendly_colour_yellow: + if self._friendly_colour_yellow == is_friendly: self._proto.yellow_robots[robot_id].CopyFrom(robot_state) else: self._proto.blue_robots[robot_id].CopyFrom(robot_state) - def remove_robot(self, robot_id: int) -> None: + def remove_robot(self, robot_id: int, is_friendly: bool) -> None: """Removes a robot from the world state :param robot_id: the id of the robot to remove """ - if self._friendly_colour_yellow: + if self._friendly_colour_yellow == is_friendly: del self._proto.yellow_robots[robot_id] else: del self._proto.blue_robots[robot_id] @@ -177,7 +180,7 @@ def __init__( self.robot_remove_double_click = None # the selected robot for moving - self.selected_robot_id = None + self.selected_robot: Tuple[int, bool] = None self.selected_robot_pos = None self.selected_robot_plane = None self.move_in_progress = False @@ -185,7 +188,7 @@ def __init__( # the currently added robots and the next id to add self.friendly_next_id = 0 self.enemy_next_id = 0 - + self.friendly_curr_robots: set[int] = set() self.enemy_curr_robots: set[int] = set() self.curr_robot_ids_map: dict[bool, set[int]] = { @@ -200,9 +203,14 @@ def __init__( self.redo_operations = [] self.sandbox_mode_enabled = False + self.should_add_friendly = True self._referee_defined = False + local_robot_state_provider_instance.register_callback( + self._update_robots_graphics + ) + @override def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: """Requires Ctrl + Shift to be pressed along with mouse click @@ -227,13 +235,9 @@ def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: # determine whether a robot was clicked robot_id, index, is_friendly = self.__identify_robot(event.multi_plane_points) - # if an enemy robot is at the mouse position, return - if robot_id is not None and not is_friendly: - return - if robot_id is None: # if no robot was clicked - self.__handle_new_robot_event(event, is_friendly) + self.__handle_new_robot_event(event) else: # if a robot was clicked try: @@ -243,11 +247,6 @@ def mouse_in_scene_pressed(self, event: MouseInSceneEvent) -> None: # self.__display_last_remove_warning(event) return - # robots are normally auto-rendered by refresh fn when sim is unpaused - # when sim is paused, have to manually render - if not self.is_playing: - self._update_robots_graphics() - @override def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: """Requires Ctrl + Shift to be pressed along with mouse click @@ -270,12 +269,14 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: return # if robot is selected - if self.selected_robot_id is not None and self.selected_robot_plane is not None: + if self.selected_robot is not None and self.selected_robot_plane is not None: + selected_robot_id, is_friendly = self.selected_robot + # get the new position on the plane that the robot was initially selected on point_on_current_plane = event.multi_plane_points[self.selected_robot_plane] # check if the mouse position is free or not - robot_id, _, is_friendly = self.__identify_robot([point_on_current_plane]) + robot_id, _, _ = self.__identify_robot([point_on_current_plane]) # skip if new position has a robot already if robot_id is not None: @@ -290,7 +291,7 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: # add an undo operation to restore the robot to the position before moving self.__add_undo_operation( RobotOperation( - self.selected_robot_id, + selected_robot_id, point_on_current_plane, self.selected_robot_pos, self.friendly_next_id, @@ -303,17 +304,12 @@ def mouse_in_scene_dragged(self, event: MouseInSceneEvent) -> None: # update selected robot position self.__update_world_state( - self.selected_robot_id, + selected_robot_id, point_on_current_plane, self.DEFAULT_ROBOT_ANGLE, is_friendly=is_friendly, ) - # robots are normally auto-rendered by refresh fn when sim is unpaused - # when sim is paused, have to manually render - if not self.is_playing: - self._update_robots_graphics() - @override def mouse_in_scene_released(self, event: MouseInSceneEvent) -> None: """Reset the selected robot and the in progress move @@ -327,7 +323,7 @@ def mouse_in_scene_released(self, event: MouseInSceneEvent) -> None: return # ends the currently happening move - self.selected_robot_id = None + self.selected_robot = None self.selected_robot_pos = None self.selected_robot_plane = None self.move_in_progress = False @@ -354,7 +350,11 @@ def refresh_graphics(self) -> None: for robot in self.cached_world.friendly_team.team_robots: self.friendly_curr_robots.add(robot.id) + for robot in self.cached_world.enemy_team.team_robots: + self.enemy_curr_robots.add(robot.id) + self.friendly_next_id = len(self.friendly_curr_robots) + self.enemy_next_id = len(self.enemy_curr_robots) self.should_init_curr_robot_ids = False def __check_referee_status(self) -> None: @@ -389,11 +389,6 @@ def undo(self) -> None: self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) - # robots are normally auto-rendered by refresh fn when sim is unpaused - # when sim is paused, have to manually render - if not self.is_playing: - self._update_robots_graphics() - def redo(self) -> None: """Redoes the last undo operation Adds a corresponding opposite move to the undo list so we can undo if necessary @@ -417,11 +412,6 @@ def redo(self) -> None: self.undo_toggle_enabled_signal.emit(len(self.undo_operations) != 0) self.redo_toggle_enabled_signal.emit(len(self.redo_operations) != 0) - # robots are normally auto-rendered by refresh fn when sim is unpaused - # when sim is paused, have to manually render - if not self.is_playing: - self._update_robots_graphics() - def clear_field(self) -> None: """Removes all robots from the field. Adds a GroupOperation to the undo list that can restore all robots. @@ -448,7 +438,12 @@ def clear_field(self) -> None: ) # then, update with local state - for robot_id, pos_and_orient in local_robot_state_provider_instance.get_team_state(self.friendly_colour_yellow).items(): + for ( + robot_id, + pos_and_orient, + ) in local_robot_state_provider_instance.get_team_state( + self.friendly_colour_yellow + ).items(): # if the local robot has already been removed, skip it if pos_and_orient is None: continue @@ -482,11 +477,6 @@ def clear_field(self) -> None: self.simulator_io.send_proto(WorldState, world_state.proto) - # robots are normally auto-rendered by refresh fn when sim is unpaused - # when sim is paused, have to manually render - if not self.is_playing: - self._update_robots_graphics() - @override def toggle_play_state(self) -> bool: """When the simulator is paused / played, reset the local positions @@ -522,6 +512,14 @@ def toggle_sandbox_mode(self) -> bool: return self.sandbox_mode_enabled + def set_adding_team(self, team: Team) -> None: + """Updates which team new robots will be added to based on the + selected team and the friendly colour. + + :param team: the Team enum value (Team.Yellow or Team.Blue) + """ + self.should_add_friendly = (team == Team.YELLOW) == self.friendly_colour_yellow + def __add_undo_operation(self, operation: RobotOperation) -> None: """Adds an undo operation to the list and emits the toggle enable signal @@ -548,9 +546,7 @@ def __undo_redo_internal(self, operation: Operation) -> None: min(self.friendly_next_id, inner_op.next_id) ) else: - self.enemy_next_id = int( - min(self.enemy_next_id, inner_op.next_id) - ) + self.enemy_next_id = int(min(self.enemy_next_id, inner_op.next_id)) world_state = self.__update_with_new_position( world_state, inner_op.id, @@ -591,10 +587,8 @@ def __get_empty_world_state(self) -> SandboxWorldState: center_circle_radius = self.cached_world.field.center_circle_radius robot_pos = QVector3D(-center_circle_radius, 0, 0) - for robot_id in local_robot_state_provider_instance.get_team_state(self.friendly_colour_yellow).keys(): - local_robot_state_provider_instance.remove_robot(robot_id, self.friendly_colour_yellow) - for robot_id in local_robot_state_provider_instance.get_team_state(not self.friendly_colour_yellow).keys(): - local_robot_state_provider_instance.remove_robot(robot_id, not self.friendly_colour_yellow) + local_robot_state_provider_instance.clear_team(self.friendly_colour_yellow) + local_robot_state_provider_instance.clear_team(not self.friendly_colour_yellow) world_state = self.__update_with_new_position( world_state, @@ -631,7 +625,7 @@ def __handle_existing_robot_event( """ # marks the robot as selected along with the plane index that the mouse click intersected with # and its current position on that plane - self.selected_robot_id = robot_id + self.selected_robot = robot_id, is_friendly self.selected_robot_pos = event.multi_plane_points[index] self.selected_robot_plane = index @@ -685,15 +679,12 @@ def __display_last_remove_warning(self, event: MouseInSceneEvent) -> None: ], ) - def __handle_new_robot_event( - self, event: MouseInSceneEvent, is_friendly: bool - ) -> None: + def __handle_new_robot_event(self, event: MouseInSceneEvent) -> None: """Handles a mouse event when an empty position is clicked If double clicked, adds a new robot at that position Else, starts a double click :param event: the mouse event with the new robot's position - :param is_friendly: whether the robot is on the friendly team """ # if the current point is a double click in progress if ( @@ -701,7 +692,9 @@ def __handle_new_robot_event( and self.robot_add_double_click == event.point_in_scene ): curr_next_id = ( - self.friendly_next_id if is_friendly else self.enemy_next_id + self.friendly_next_id + if self.should_add_friendly + else self.enemy_next_id ) # add an undo operation to remove the robot that is being added @@ -711,7 +704,7 @@ def __handle_new_robot_event( event.point_in_scene, None, curr_next_id, - is_friendly, + self.should_add_friendly, ) ) @@ -720,14 +713,14 @@ def __handle_new_robot_event( curr_next_id, event.point_in_scene, self.DEFAULT_ROBOT_ANGLE, - is_friendly=is_friendly, + is_friendly=self.should_add_friendly, ) new_next_id = self.__get_next_robot_id( - curr_next_id, self.curr_robot_ids_map[is_friendly] + curr_next_id, self.curr_robot_ids_map[self.should_add_friendly] ) - if is_friendly: + if self.should_add_friendly: self.friendly_next_id = new_next_id else: self.enemy_next_id = new_next_id @@ -763,8 +756,11 @@ def __toggle_robot_remove_double_click(self) -> None: if self.robot_remove_double_click: self.robot_remove_double_click = None - def __identify_robot( - self, multi_plane_points: list[QVector3D] + def __identify_robot_with_locals( + self, + multi_plane_points: list[QVector3D], + team: Team, + local_state_map: dict[int, tuple[QVector3D, float] | None], ) -> tuple[Optional[int], Optional[int]]: """Check local state first, then team robots, for a robot at the given position @@ -812,7 +808,9 @@ def __identify_robot( robot_id, index = self.__identify_robot_with_locals( multi_plane_points, self.cached_world.friendly_team, - self.friendly_local_robot_positions, + local_robot_state_provider_instance.get_team_state( + self.friendly_colour_yellow + ), ) if robot_id is not None: @@ -822,7 +820,9 @@ def __identify_robot( robot_id, index = self.__identify_robot_with_locals( multi_plane_points, self.cached_world.enemy_team, - self.enemy_local_robot_positions, + local_robot_state_provider_instance.get_team_state( + not self.friendly_colour_yellow + ), ) if robot_id is not None: @@ -854,27 +854,45 @@ def __get_curr_world_state(self) -> SandboxWorldState: self.__invert_robot_if_defending_negative_half, self.friendly_colour_yellow ) + world_state = self.__get_current_world_state_for_team(world_state, True) + world_state = self.__get_current_world_state_for_team(world_state, False) + + return world_state + + def __get_current_world_state_for_team( + self, world_state: SandboxWorldState, is_friendly: bool + ) -> SandboxModeState: + team_robots = ( + self.cached_world.friendly_team.team_robots + if is_friendly + else self.cached_world.enemy_team.team_robots + ) + # copy over existing robots for the current team - for robot_ in self.cached_world.friendly_team.team_robots: + for robot_ in team_robots: world_state.add_robot( robot_.id, - QVector3D( + pos=QVector3D( robot_.current_state.global_position.x_meters, robot_.current_state.global_position.y_meters, 0, ), - robot_.current_state.global_orientation.radians, - is_friendly=True, + orientation=robot_.current_state.global_orientation.radians, + is_friendly=is_friendly, ) # copy over any local state robots if sim is paused if not self.is_playing: - for robot_id, pos in self.friendly_local_robot_positions.items(): + for robot_id, pos in local_robot_state_provider_instance.get_team_state( + self.friendly_colour_yellow == is_friendly + ).items(): # if the robot has already been removed, skip it if pos is None: continue - world_state.add_robot(robot_id, pos[0], pos[1]) + world_state.add_robot( + robot_id, pos=pos[0], orientation=pos[1], is_friendly=is_friendly + ) return world_state @@ -917,7 +935,7 @@ def __update_with_new_position( robot_id: int, new_pos: Optional[QVector3D], new_orientation: float = 0, - is_friendly: bool = True + is_friendly: bool = True, ) -> SandboxWorldState: """Updates the world state with the new robot position for the given id New position is defined if adding / moving a robot and None if removing one @@ -931,18 +949,22 @@ def __update_with_new_position( """ if new_pos: self.curr_robot_ids_map[is_friendly].add(robot_id) - world_state.add_robot(robot_id, new_pos, new_orientation) + world_state.add_robot(robot_id, new_pos, new_orientation, is_friendly) # saves the state to local dict - self.local_robot_positions_map[is_friendly][robot_id] = ( + local_robot_state_provider_instance.update_robot( + robot_id, new_pos, new_orientation, + self.friendly_colour_yellow == is_friendly, ) else: # remove an existing robot self.curr_robot_ids_map[is_friendly].remove(robot_id) - self.local_robot_positions_map[is_friendly][robot_id] = None - world_state.remove_robot(robot_id) + local_robot_state_provider_instance.remove_robot( + robot_id, self.friendly_colour_yellow == is_friendly + ) + world_state.remove_robot(robot_id, is_friendly) return world_state @@ -1001,10 +1023,16 @@ def _update_robots_graphics(self) -> None: """ if not self.is_playing: self.__override_cache_with_locals( - self.friendly_local_robot_positions, self._cached_friendly_team + local_robot_state_provider_instance.get_team_state( + self.friendly_colour_yellow + ), + self._cached_friendly_team, ) self.__override_cache_with_locals( - self.enemy_local_robot_positions, self._cached_enemy_team + local_robot_state_provider_instance.get_team_state( + not self.friendly_colour_yellow + ), + self._cached_enemy_team, ) super()._update_robots_graphics() diff --git a/src/software/thunderscope/gl/sandbox/BUILD b/src/software/thunderscope/gl/sandbox/BUILD index 7ffb54ae1d..10b257bfe6 100644 --- a/src/software/thunderscope/gl/sandbox/BUILD +++ b/src/software/thunderscope/gl/sandbox/BUILD @@ -11,3 +11,11 @@ py_library( requirement("qtawesome"), ], ) + +py_library( + name = "local_robot_state_provider", + srcs = ["local_robot_state_provider.py"], + deps = [ + requirement("pyqtgraph"), + ], +) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 91214d14c3..a46b8876b1 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -2,6 +2,7 @@ from pyqtgraph.Qt import QtCore from pyqtgraph.Qt.QtWidgets import * from proto.import_all_protos import * +from proto.ssl_gc_common_pb2 import Team from software.thunderscope.thread_safe_buffer import ThreadSafeBuffer from software.thunderscope.proto_unix_io import ProtoUnixIO import qtawesome as qta @@ -22,7 +23,9 @@ class GLSandboxSidebar(QWidget): POSITION_PADDING_MULTIPLIER = 0.1 SIDEBAR_WIDTH_RATIO = 0.2 - def __init__(self, parent: QWidget, widget_above: QWidget, simulator_io: ProtoUnixIO): + def __init__( + self, parent: QWidget, widget_above: QWidget, simulator_io: ProtoUnixIO + ): """Set up the sandbox sidebar :param parent: the parent widget to attach this sidebar to @@ -39,6 +42,7 @@ def __init__(self, parent: QWidget, widget_above: QWidget, simulator_io: ProtoUn self.sidebar_enabled = False self.sidebar_rendered = False self._sandbox_toggle_callback: Callable[[], None] | None = None + self._toggle_add_team_callback: Callable[[Team], None] | None = None # Create a container widget to hold all sidebar contents self.sidebar_container = QWidget() @@ -120,6 +124,18 @@ def __init__(self, parent: QWidget, widget_above: QWidget, simulator_io: ProtoUn button_layout.addWidget(self.redo_button) self.sidebar_container.layout().addStretch() self.sidebar_container.layout().addLayout(button_layout) + self.sidebar_container.layout().addStretch() + + # Setup team selector for adding robots + self.team_label = QLabel("Add to Team:") + self.team_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.sidebar_container.layout().addWidget(self.team_label) + + self.team_combo = QComboBox() + self.team_combo.addItem(Team.Name(Team.BLUE), Team.BLUE) + self.team_combo.addItem(Team.Name(Team.YELLOW), Team.YELLOW) + self.team_combo.currentIndexChanged.connect(self.set_adding_team) + self.sidebar_container.layout().addWidget(self.team_combo) # Setup Clear Field button self.clear_field_button = ToggleableButton(False) @@ -128,8 +144,6 @@ def __init__(self, parent: QWidget, widget_above: QWidget, simulator_io: ProtoUn self.sidebar_container.layout().addStretch() self.sidebar_container.layout().addWidget(self.clear_field_button) - self.sidebar_container.layout().addStretch() - # Add the container to the main layout, with stretch underneath self.layout().addWidget(self.sidebar_container) self.layout().addStretch() @@ -161,13 +175,9 @@ def refresh(self) -> None: block=False, return_cached=False ) if sandbox_mode_state: - self.sandbox_mode_checkbox.setChecked( - sandbox_mode_state.is_enabled - ) + self.sandbox_mode_checkbox.setChecked(sandbox_mode_state.is_enabled) self.pause_button.toggle_enabled(sandbox_mode_state.is_enabled) - self.clear_field_button.toggle_enabled( - sandbox_mode_state.is_enabled - ) + self.clear_field_button.toggle_enabled(sandbox_mode_state.is_enabled) self.undo_button.toggle_enabled( self.undo_button_enabled and sandbox_mode_state.is_enabled ) @@ -175,6 +185,22 @@ def refresh(self) -> None: self.redo_button_enabled and sandbox_mode_state.is_enabled ) + def set_adding_team(self, index: int) -> None: + """Called when the team combo box selection changes. + Invokes the registered callback with the selected Team enum value. + + :param index: the index of the selected team + """ + if self._toggle_add_team_callback: + self._toggle_add_team_callback(self.team_combo.currentData()) + + def set_add_team_callback(self, callback: Callable[[Team], None]) -> None: + """Sets the callback to call when the team selection changes. + + :param callback: A callable that takes the selected Team enum value. + """ + self._toggle_add_team_callback = callback + def set_sandbox_toggle_callback(self, callback: Callable[[], None]) -> None: """Sets the callback to call when sandbox mode is toggled. diff --git a/src/software/thunderscope/gl/sandbox/local_robot_state_provider.py b/src/software/thunderscope/gl/sandbox/local_robot_state_provider.py index 3512f10fad..a7825821db 100644 --- a/src/software/thunderscope/gl/sandbox/local_robot_state_provider.py +++ b/src/software/thunderscope/gl/sandbox/local_robot_state_provider.py @@ -1,3 +1,5 @@ +from typing import Callable + from pyqtgraph.Qt.QtGui import QVector3D @@ -12,9 +14,21 @@ def __init__(self) -> None: """Constructs a LocalRobotStateProvider with empty state""" # map of robot id to a tuple with the robot coordinates and orientation # or None if the robot has been removed already - # (easier to keep track of robots rather than removing the entry entirely) self.yellow_robot_states: dict[int, tuple[QVector3D, float] | None] = {} self.blue_robot_states: dict[int, tuple[QVector3D, float] | None] = {} + self._callbacks: list[Callable[[], None]] = [] + + def register_callback(self, callback: Callable[[], None]) -> None: + """Register a callback to be invoked when local robot state changes + + :param callback: function to call when robot state changes + """ + self._callbacks.append(callback) + + def _invoke_callbacks(self) -> None: + """Invoke all registered callbacks""" + for callback in self._callbacks: + callback() def update_robot( self, @@ -35,6 +49,8 @@ def update_robot( else: self.blue_robot_states[robot_id] = (position, orientation) + self._invoke_callbacks() + def remove_robot(self, robot_id: int, is_yellow: bool) -> None: """Mark a robot as removed in local state @@ -46,6 +62,8 @@ def remove_robot(self, robot_id: int, is_yellow: bool) -> None: else: self.blue_robot_states[robot_id] = None + self._invoke_callbacks() + def get_team_state( self, is_yellow: bool ) -> dict[int, tuple[QVector3D, float] | None]: diff --git a/src/software/thunderscope/widget_setup_functions.py b/src/software/thunderscope/widget_setup_functions.py index 4a37dff4b8..48bb5f01a2 100644 --- a/src/software/thunderscope/widget_setup_functions.py +++ b/src/software/thunderscope/widget_setup_functions.py @@ -178,6 +178,7 @@ def setup_gl_widget( # connect all sandbox controls sandbox_sidebar.set_sandbox_toggle_callback(world_layer.toggle_sandbox_mode) + sandbox_sidebar.set_add_team_callback(world_layer.set_adding_team) sandbox_sidebar.pause_button.clicked.connect(world_layer.toggle_play_state) sandbox_sidebar.undo_button.clicked.connect(world_layer.undo) sandbox_sidebar.redo_button.clicked.connect(world_layer.redo) From 47810dfb812d6bab1a79a3040bda6d639a8847ce Mon Sep 17 00:00:00 2001 From: sauravbanna Date: Thu, 2 Jul 2026 05:45:00 -0700 Subject: [PATCH 18/19] add radio button group for teams --- .../thunderscope/gl/sandbox/gl_sandbox_sidebar.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index a46b8876b1..7d934d49ac 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -131,6 +131,18 @@ def __init__( self.team_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.sidebar_container.layout().addWidget(self.team_label) + self.team_group = QButtonGroup() + + team_buttons = [] + for team in [Team.BLUE, Team.YELLOW]: + radio_button = QRadioButton(Team.Name(team)) + self.team_group.addButton(radio_button, team) + self.sidebar_container.layout().addWidget(radio_button) + team_buttons.append(radio_button) + + self.team_group.idClicked.connect(self.set_adding_team) + team_buttons[0].setChecked(True) + self.team_combo = QComboBox() self.team_combo.addItem(Team.Name(Team.BLUE), Team.BLUE) self.team_combo.addItem(Team.Name(Team.YELLOW), Team.YELLOW) From 313b77ab72cf87cb91cc3d3a050a605bc314ae67 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:06:34 +0000 Subject: [PATCH 19/19] [pre-commit.ci lite] apply automatic fixes --- src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py index 7d934d49ac..460a56f9e4 100644 --- a/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py +++ b/src/software/thunderscope/gl/sandbox/gl_sandbox_sidebar.py @@ -136,7 +136,7 @@ def __init__( team_buttons = [] for team in [Team.BLUE, Team.YELLOW]: radio_button = QRadioButton(Team.Name(team)) - self.team_group.addButton(radio_button, team) + self.team_group.addButton(radio_button, team) self.sidebar_container.layout().addWidget(radio_button) team_buttons.append(radio_button)