diff --git a/CHANGELOG.md b/CHANGELOG.md index a367e3e789..3466e6c247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `compas_viewer.commands.capture_view` and corresponding command. * Added default colors to `MeshObject`. * Added default colors to `GeometryObject`. +* Added `object_info_cmd` for `compas_viewer.commends`. * Added `gridmode` to `GridObject`. ### Changed diff --git a/scripts/example.py b/scripts/example.py index 0919eba8e9..e4b5e0d8a9 100644 --- a/scripts/example.py +++ b/scripts/example.py @@ -8,7 +8,7 @@ viewer = Viewer(show_grid=True) mesh = Mesh.from_off(compas.get("tubemesh.off")) -obj = viewer.scene.add(mesh, show_points=True, facecolor=Color.blue(), linecolor=Color.red(), pointcolor=Color.green()) +obj = viewer.scene.add(mesh, show_points=True) N = 10 M = 10 diff --git a/src/compas_viewer/commands.py b/src/compas_viewer/commands.py index 1e310c5f5a..92652d07b3 100644 --- a/src/compas_viewer/commands.py +++ b/src/compas_viewer/commands.py @@ -19,7 +19,8 @@ import compas from compas.scene import Scene -from compas_viewer.components import CameraSettingsDialog +from compas_viewer.components.camerasetting import CameraSettingsDialog +from compas_viewer.components.objectsetting import ObjectSettingDialog if TYPE_CHECKING: from compas_viewer import Viewer @@ -460,3 +461,19 @@ def load_data(): load_data_cmd = Command(title="Load Data", callback=lambda: print("load data")) + + +# ============================================================================= +# ============================================================================= +# ============================================================================= +# Info +# ============================================================================= +# ============================================================================= +# ============================================================================= + + +def obj_settings(viewer: "Viewer"): + ObjectSettingDialog().exec() + + +obj_settings_cmd = Command(title="Object Settings", callback=obj_settings) diff --git a/src/compas_viewer/components/__init__.py b/src/compas_viewer/components/__init__.py index c74d1403f8..6e0fc2e336 100644 --- a/src/compas_viewer/components/__init__.py +++ b/src/compas_viewer/components/__init__.py @@ -1,7 +1,8 @@ from .button import Button from .combobox import ComboBox from .combobox import ViewModeAction -from .dialog import CameraSettingsDialog +from .camerasetting import CameraSettingsDialog +from .objectsetting import ObjectSettingDialog from .slider import Slider from .treeform import Treeform from .sceneform import Sceneform @@ -10,6 +11,7 @@ "Button", "ComboBox", "CameraSettingsDialog", + "ObjectSettingDialog", "Renderer", "Slider", "Treeform", diff --git a/src/compas_viewer/components/camerasetting.py b/src/compas_viewer/components/camerasetting.py new file mode 100644 index 0000000000..ade97bbdd7 --- /dev/null +++ b/src/compas_viewer/components/camerasetting.py @@ -0,0 +1,81 @@ +from PySide6.QtWidgets import QDialog +from PySide6.QtWidgets import QPushButton +from PySide6.QtWidgets import QVBoxLayout + +from compas_viewer.base import Base +from compas_viewer.components.layout import base_layout + + +class CameraSettingsDialog(QDialog, Base): + """ + A dialog for displaying and updating camera settings in Qt applications. + This dialog allows users to modify the camera's target and position and + applies these changes dynamically. + + Attributes + ---------- + layout : QVBoxLayout + The layout of the dialog. + spin_boxes : dict + Dictionary containing spin boxes for adjusting camera settings. + update_button : QPushButton + Button to apply changes to the camera settings. + camera : Camera + The camera object from the viewer's renderer. + + Methods + ------- + update() + Updates the camera's target and position and closes the dialog. + + Example + ------- + >>> dialog = CameraSettingsDialog() + >>> dialog.exec() + """ + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Camera Settings") + + self.layout = QVBoxLayout(self) + self.camera = self.viewer.renderer.camera + items = [ + { + "title": "Camera_Target", + "items": [ + {"type": "double_edit", "title": "X", "value": self.camera.target.x, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Y", "value": self.camera.target.y, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Z", "value": self.camera.target.z, "min_val": None, "max_val": None}, + ], + }, + { + "title": "Camera_Position", + "items": [ + {"type": "double_edit", "title": "X", "value": self.camera.position.x, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Y", "value": self.camera.position.y, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Z", "value": self.camera.position.z, "min_val": None, "max_val": None}, + ], + }, + ] + + camera_setting_layout, self.spin_boxes = base_layout(items) + + self.layout.addLayout(camera_setting_layout) + + self.update_button = QPushButton("Update Camera", self) + self.update_button.clicked.connect(self.update) + self.layout.addWidget(self.update_button) + + def update(self) -> None: + self.viewer.renderer.camera.target.set( + self.spin_boxes["Camera_Target_X"].spinbox.value(), + self.spin_boxes["Camera_Target_Y"].spinbox.value(), + self.spin_boxes["Camera_Target_Z"].spinbox.value(), + ) + self.viewer.renderer.camera.position.set( + self.spin_boxes["Camera_Position_X"].spinbox.value(), + self.spin_boxes["Camera_Position_Y"].spinbox.value(), + self.spin_boxes["Camera_Position_Z"].spinbox.value(), + ) + self.accept() diff --git a/src/compas_viewer/components/combobox.py b/src/compas_viewer/components/combobox.py index a05a925d3e..18857211c3 100644 --- a/src/compas_viewer/components/combobox.py +++ b/src/compas_viewer/components/combobox.py @@ -1,30 +1,263 @@ +from typing import TYPE_CHECKING +from typing import Callable +from typing import Optional + +from PySide6.QtCore import QSize +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtGui import QPainter from PySide6.QtWidgets import QComboBox +from PySide6.QtWidgets import QStyledItemDelegate from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QWidget +from compas.colors import Color +from compas.colors.colordict import ColorDict from compas_viewer.base import Base +if TYPE_CHECKING: + from compas_viewer.scene import ViewerSceneObject + + +def remap_rgb(value, to_range_one=True): + """ + Remap an RGB value between the range (0, 255) and (0, 1). + + Parameters + ---------- + value : tuple + The RGB value to remap. + to_range_one : bool, optional + If True, remap from (0, 255) to (0, 1). If False, remap from (0, 1) to (0, 255). + + Returns + ------- + tuple + The remapped RGB value. + """ + factor = 1 / 255 if to_range_one else 255 + return tuple(v * factor for v in value) + + +class ColorDelegate(QStyledItemDelegate): + def paint(self, painter, option, index): + painter.save() + + # Get the color from the model data + color = index.data(Qt.UserRole) + if isinstance(color, QColor): + # Draw the color rectangle + painter.fillRect(option.rect, color) + painter.restore() + + def sizeHint(self, option, index): + # Set a fixed size for the items + return QSize(100, 20) + class ComboBox(QComboBox): - def __init__(self, items, change_callback): + """ + A customizable combo box widget, supporting item-specific rendering with an optional color delegate. + + Parameters + ---------- + items : list + List of items to populate the combo box. + change_callback : Callable + Function to execute on value change. + Should accept a single argument corresponding to the selected item's data. + paint : bool, optional + Whether to use a custom delegate for item rendering, such as displaying colors. Defaults to None. + + Attributes + ---------- + paint : bool + Flag indicating whether custom item rendering is enabled. + assigned_color : QColor or None + The color assigned to the combo box before any changes. + is_changed : bool + Indicates if the color has been changed through user interaction. + + Methods + ------- + populate(items: list) -> None + Populates the combo box with items. + setAssignedColor(color: QColor) -> None + Sets the assigned color to be displayed when the item is not changed. + on_index_changed(change_callback: Callable, index: int) -> None + Handles the index change event and triggers the callback. + paintEvent(event) -> None + Custom painting for the combo box, used when `paint` is True. + + Example + ------- + >>> items = [("Red", QColor(255, 0, 0)), ("Green", QColor(0, 255, 0)), ("Blue", QColor(0, 0, 255))] + >>> combobox = ComboBox(items, change_callback=lambda x: print(x), paint=True) + """ + + def __init__( + self, + items: list = None, + change_callback: Callable = None, + paint: Optional[bool] = None, + ): super().__init__() - self.populate(items) - self.currentIndexChanged.connect(lambda index: change_callback(self.itemData(index))) + self.paint = paint + self.assigned_color = None + self.is_changed = False + + if self.paint: + self.setItemDelegate(ColorDelegate()) + + if items: + self.populate(items) - def populate(self, items): + if change_callback: + self.currentIndexChanged.connect(lambda index: self.on_index_changed(change_callback, index)) + + def populate(self, items: list) -> None: """ Populate the combo box with items. - :param items: List of tuples, each containing the display text and user data + Parameters + ---------- + items : list + List of tuples, each containing the display text and user data """ for item in items: - self.addItem(item, item) + if self.paint: + self.addItem("", item) + index = self.model().index(self.count() - 1, 0) + self.model().setData(index, item, Qt.UserRole) + else: + self.addItem(item, item) + + def setAssignedColor(self, color: QColor) -> None: + """ + Sets the assigned color to be displayed when the item is not changed. + + Parameters + ---------- + color : QColor + The color to be assigned to the combo box. + """ + self.assigned_color = color + + def on_index_changed(self, change_callback: Callable, index: int) -> None: + """ + Handles the index change event and triggers the callback. + + Parameters + ---------- + change_callback : Callable + Function to execute on value change. + index : int + The new index of the selected item. + """ + self.is_changed = True + change_callback(self.itemData(index)) + self.update() + + def paintEvent(self, event) -> None: + painter = QPainter(self) + rect = self.rect() + + color = self.currentData(Qt.UserRole) if self.is_changed else self.assigned_color + + if isinstance(color, QColor): + painter.fillRect(rect, color) + else: + super().paintEvent(event) + painter.end() + + +class ColorComboBox(QWidget, Base): + """ + A custom QWidget for selecting colors from a predefined list and applying the selected color to an object's attribute. + + Parameters + ---------- + obj : ViewerSceneObject, optional + The object to which the selected color will be applied. Defaults to None. + attr : str, optional + The attribute of the object to which the selected color will be applied. Defaults to None. + + Attributes + ---------- + obj : ViewerSceneObject + The object to which the selected color will be applied. + attr : str + The attribute of the object to which the selected color will be applied. + color_options : list of QColor + A list of predefined QColor objects representing available colors. + layout : QVBoxLayout + The layout of the widget. + color_selector : ComboBox + A combo box for selecting colors. + + Methods + ------- + change_color(color: QColor) -> None + Changes the color of the object's attribute to the selected color. + + Example + ------- + >>> color_combobox = ColorComboBox(obj=some_obj, attr="linecolor") + >>> color_combobox.show() + """ + + def __init__( + self, + obj: "ViewerSceneObject" = None, + attr: str = None, + ): + super().__init__() + self.obj = obj + self.attr = attr + + self.color_options = [ + QColor(255, 255, 255), # White + QColor(211, 211, 211), # LightGray + QColor(190, 190, 190), # Gray + QColor(0, 0, 0), # Black + QColor(255, 0, 0), # Red + QColor(0, 255, 0), # Green + QColor(0, 0, 255), # Blue + QColor(255, 255, 0), # Yellow + QColor(0, 255, 255), # Cyan + QColor(255, 0, 255), # Magenta + ] + + default_color = getattr(self.obj, self.attr) + + if isinstance(default_color, Color): + default_color = default_color.rgb + elif isinstance(default_color, ColorDict): + default_color = default_color.default + else: + raise ValueError("Invalid color type.") + default_color = QColor(*remap_rgb(default_color, to_range_one=False)) + + self.layout = QVBoxLayout(self) + self.color_selector = ComboBox(self.color_options, self.change_color, paint=True) + self.color_selector.setAssignedColor(default_color) + self.layout.addWidget(self.color_selector) + + def change_color(self, color): + rgb = remap_rgb(color.getRgb())[:-1] # rgba to rgb(0-1) + setattr(self.obj, self.attr, Color(*rgb)) + self.obj.update() class ViewModeAction(QWidget, Base): def __init__(self): super().__init__() - self.view_options = ["perspective", "top", "front", "right"] + self.view_options = [ + "perspective", + "top", + "front", + "right", + ] def combobox(self): self.layout = QVBoxLayout(self) diff --git a/src/compas_viewer/components/dialog.py b/src/compas_viewer/components/dialog.py deleted file mode 100644 index 82e3a6d6d9..0000000000 --- a/src/compas_viewer/components/dialog.py +++ /dev/null @@ -1,88 +0,0 @@ -from PySide6.QtWidgets import QDialog -from PySide6.QtWidgets import QHBoxLayout -from PySide6.QtWidgets import QLabel -from PySide6.QtWidgets import QPushButton -from PySide6.QtWidgets import QVBoxLayout - -from compas_viewer.base import Base -from compas_viewer.components.double_edit import DoubleEdit - - -class CameraSettingsDialog(QDialog, Base): - """ - Dialog for adjusting camera settings in a graphical user interface. - - This dialog allows users to dynamically modify the camera's position and target coordinates - through the use of spin boxes for each coordinate axis (X, Y, Z). - - Attributes - ---------- - layout : QVBoxLayout - The layout for arranging widgets vertically within the dialog. - spin_boxes : dict - Stores references to spin box widgets for easy access when updating camera settings. - update_button : QPushButton - Button for applying the updated camera settings and closing the dialog. - - Notes - ----- - This class assumes that there is an existing viewer with a renderer and camera attribute. - It should be used where these components are already established and accessible. - - Examples - -------- - Assuming `viewer` is an instance with a renderer and camera attributes: - - >>> dialog = CameraSettingsDialog() - >>> dialog.exec_() # Executing the dialog to allow user interaction - - References - ---------- - * https://doc.qt.io/qt-6/qdialog.html - * https://doc.qt.io/qt-6/qlayout.html - * https://doc.qt.io/qt-6/qpushbutton.html - - """ - - def __init__(self) -> None: - super().__init__() - self.setWindowTitle("Camera Settings") - - self.layout = QVBoxLayout(self) - self.spin_boxes = {} - current_camera = self.viewer.renderer.camera - # set None to infinity error - coordinates = { - "Camera_Target": [("X", current_camera.target.x, None, None), ("Y", current_camera.target.y, None, None), ("Z", current_camera.target.z, None, None)], - "Camera_Position": [("X", current_camera.position.x, None, None), ("Y", current_camera.position.y, None, None), ("Z", current_camera.position.z, None, None)], - } - - for coord in coordinates: - spin_box_layout = QHBoxLayout() - label = QLabel(f"{coord}:", self) - spin_box_layout.addWidget(label) - - for setting in coordinates[coord]: - widget = DoubleEdit(*setting) - spin_box_layout.addWidget(widget) - self.spin_boxes[coord + "_" + setting[0]] = widget - - self.layout.addLayout(spin_box_layout) - - # Update button - self.update_button = QPushButton("Update Camera", self) - self.update_button.clicked.connect(self.updateCameraTarget) - self.layout.addWidget(self.update_button) - - def updateCameraTarget(self) -> None: - self.viewer.renderer.camera.target.set( - self.spin_boxes["Camera_Target_X"].spinbox.value(), - self.spin_boxes["Camera_Target_Y"].spinbox.value(), - self.spin_boxes["Camera_Target_Z"].spinbox.value(), - ) - self.viewer.renderer.camera.position.set( - self.spin_boxes["Camera_Position_X"].spinbox.value(), - self.spin_boxes["Camera_Position_Y"].spinbox.value(), - self.spin_boxes["Camera_Position_Z"].spinbox.value(), - ) - self.accept() # Close the dialog diff --git a/src/compas_viewer/components/double_edit.py b/src/compas_viewer/components/double_edit.py index 3e1f89452d..1ae13e487f 100644 --- a/src/compas_viewer/components/double_edit.py +++ b/src/compas_viewer/components/double_edit.py @@ -4,7 +4,42 @@ class DoubleEdit(QtWidgets.QWidget): - def __init__(self, name: str = None, default: float = None, min_val: float = None, max_val: float = None): + """ + A custom QWidget for editing floating-point numbers with a label and a double spin box. + + Parameters + ---------- + title : str, optional + The label text to be displayed next to the spin box. Defaults to None. + value : float, optional + The initial value of the spin box. Defaults to None. + min_val : float, optional + The minimum value allowed in the spin box. Defaults to the smallest float value if not specified. + max_val : float, optional + The maximum value allowed in the spin box. Defaults to the largest float value if not specified. + + Attributes + ---------- + layout : QHBoxLayout + The horizontal layout containing the label and the spin box. + label : QLabel + The label displaying the title. + spinbox : QDoubleSpinBox + The double spin box for editing the floating-point number. + + Example + ------- + >>> widget = DoubleEdit(title="X", value=0.0, min_val=-10.0, max_val=10.0) + >>> widget.show() + """ + + def __init__( + self, + title: str = None, + value: float = None, + min_val: float = None, + max_val: float = None, + ): super().__init__() if min_val is None: @@ -13,18 +48,22 @@ def __init__(self, name: str = None, default: float = None, min_val: float = Non max_val = sys.float_info.max self.layout = QtWidgets.QHBoxLayout() - self.label = QtWidgets.QLabel(name) + self.label = QtWidgets.QLabel(title) self.spinbox = QtWidgets.QDoubleSpinBox() self.spinbox.setMinimum(min_val) self.spinbox.setMaximum(max_val) - self.spinbox.setValue(default) + self.spinbox.setValue(value) self.layout.addWidget(self.label) self.layout.addWidget(self.spinbox) self.setLayout(self.layout) class DoubleEditGroup(QtWidgets.QWidget): - def __init__(self, title: str, settings: list[tuple[str, float, float, float]]): + def __init__( + self, + title: str, + settings: list[tuple[str, float, float, float]], + ): super().__init__() self.layout = QtWidgets.QVBoxLayout(self) diff --git a/src/compas_viewer/components/label.py b/src/compas_viewer/components/label.py new file mode 100644 index 0000000000..688b4d700c --- /dev/null +++ b/src/compas_viewer/components/label.py @@ -0,0 +1,92 @@ +from typing import Literal +from typing import Optional + +from PySide6 import QtCore +from PySide6 import QtGui +from PySide6 import QtWidgets + + +class LabelWidget(QtWidgets.QWidget): + """ + A customizable QLabel widget for Qt applications, supporting text alignment and font size adjustments. + + Parameters + ---------- + text : str + The text to be displayed in the label. + alignment : Literal["right", "left", "center"], optional + The alignment of the text in the label. Defaults to "center". + font_size : int, optional + The font size of the text in the label. Defaults to 8. + + Attributes + ---------- + text : str + The text displayed in the label. + alignment : Literal["right", "left", "center"] + The alignment of the text in the label. + font_size : int + The font size of the text in the label. + + Methods + ------- + update_minimum_size() -> None + Updates the minimum size of the label based on the current text and font size. + + Example + ------- + >>> label_widget = LabelWidget("Ready...", alignment="right", font_size=16) + >>> label_widget.show() + """ + + def __init__(self, text: str, alignment: Literal["right", "left", "center"] = "center", font_size: Optional[int] = 8): + super().__init__() + + self.label = QtWidgets.QLabel(self) + + self.text = text + self.font_size = font_size + self.alignment = alignment + + self.layout = QtWidgets.QHBoxLayout(self) + self.layout.addWidget(self.label) + self.setLayout(self.layout) + + self.update_minimum_size() + + @property + def text(self): + return self.label.text() + + @text.setter + def text(self, value: str): + self.label.setText(value) + + @property + def alignment(self): + return self.label.alignment() + + @alignment.setter + def alignment(self, value: str): + alignments = { + "right": QtCore.Qt.AlignRight, + "left": QtCore.Qt.AlignLeft, + "center": QtCore.Qt.AlignCenter, + } + self.label.setAlignment(alignments[value]) + + @property + def font_size(self): + return self.label.font().pointSize() + + @font_size.setter + def font_size(self, value: int): + font = self.label.font() + font.setPointSize(value) + self.label.setFont(font) + + def update_minimum_size(self): + font_metrics = QtGui.QFontMetrics(self.label.font()) + text_width = font_metrics.horizontalAdvance(self.label.text()) + text_height = font_metrics.height() + self.label.setMinimumSize(text_width, text_height) diff --git a/src/compas_viewer/components/layout.py b/src/compas_viewer/components/layout.py new file mode 100644 index 0000000000..aa966d4ded --- /dev/null +++ b/src/compas_viewer/components/layout.py @@ -0,0 +1,72 @@ +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QVBoxLayout + +from compas_viewer.components.combobox import ColorComboBox +from compas_viewer.components.double_edit import DoubleEdit +from compas_viewer.components.label import LabelWidget + + +def base_layout(items: list) -> tuple[QVBoxLayout, dict]: + """ + Generates a layout for editing properties based on provided items and settings. + + Parameters + ---------- + items : list + A list of dictionaries where each dictionary represents a section with a title and items describing the widgets and their parameters. + + Returns + ------- + tuple[QVBoxLayout, dict] + A tuple containing the created layout and a dictionary of spin boxes for value adjustment. + + Example + ------- + >>> items = [ + >>> {"title": "Camera_Target", "items": [{"type": "double_edit", "title": "X", "value": 0.0, "min_val": 0.0, "max_val": 1.0}]}, + >>> {"title": "Camera_Position", "items": [{"type": "double_edit", "title": "Y", "value": 1.0, "min_val": 0.0, "max_val": 1.0}]} + >>> ] + >>> layout, spin_boxes = base_layout(items) + """ + layout = QVBoxLayout() + + spin_boxes = {} + + for item in items: + l_title = item.get("title", "") + sub_items = item.get("items", None) + + sub_layout = QHBoxLayout() + left_layout = QVBoxLayout() + right_layout = QHBoxLayout() + + label = QLabel(f"{l_title}:") + left_layout.addWidget(label) + + for sub_item in sub_items: + r_title = sub_item.get("title", "") + type = sub_item.get("type", None) + text = sub_item.get("text", "") + obj = sub_item.get("obj", None) + attr = sub_item.get("attr", None) + value = sub_item.get("value", None) + min_val = sub_item.get("min_val", None) + max_val = sub_item.get("max_val", None) + + if type == "double_edit": + widget = DoubleEdit(title=r_title, value=value, min_val=min_val, max_val=max_val) + right_layout.addWidget(widget) + spin_boxes[f"{l_title}_{r_title}"] = widget + elif type == "label": + widget = LabelWidget(text=text, alignment="right") + right_layout.addWidget(widget) + elif type == "color_combobox": + widget = ColorComboBox(obj=obj, attr=attr) + right_layout.addWidget(widget) + + sub_layout.addLayout(left_layout) + sub_layout.addLayout(right_layout) + + layout.addLayout(sub_layout) + return (layout, spin_boxes) diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py new file mode 100644 index 0000000000..3e43642c6d --- /dev/null +++ b/src/compas_viewer/components/objectsetting.py @@ -0,0 +1,182 @@ +from typing import TYPE_CHECKING + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QDialog +from PySide6.QtWidgets import QPushButton +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QWidget + +from compas_viewer.base import Base +from compas_viewer.components.layout import base_layout + +if TYPE_CHECKING: + from compas_viewer import Viewer + + +def object_setting_layout(viewer: "Viewer"): + """ + Generates a layout for displaying and editing object information based on the selected objects in the viewer. + + Parameters + ---------- + viewer : Viewer + The viewer instance containing the scene and objects. + + Returns + ------- + QVBoxLayout + The layout for displaying object information, or None if no objects are selected. + + Example + ------- + >>> layout = object_setting_layout(viewer) + """ + status = False + items = [] + for obj in viewer.scene.objects: + if obj.is_selected: + status = True + new_items = [ + {"title": "Name", "items": [{"type": "label", "text": str(obj.name)}]}, + {"title": "Parent", "items": [{"type": "label", "text": str(obj.parent)}]}, + {"title": "Point_Color", "items": [{"type": "color_combobox", "obj": obj, "attr": "pointcolor"}]}, + {"title": "Line_Color", "items": [{"type": "color_combobox", "obj": obj, "attr": "linecolor"}]}, + {"title": "Face_Color", "items": [{"type": "color_combobox", "obj": obj, "attr": "facecolor"}]}, + {"title": "Line_Width", "items": [{"type": "double_edit", "title": "", "value": obj.linewidth, "min_val": 0.0, "max_val": 10.0}]}, + {"title": "Point_Size", "items": [{"type": "double_edit", "title": "", "value": obj.pointsize, "min_val": 0.0, "max_val": 10.0}]}, + {"title": "Opacity", "items": [{"type": "double_edit", "title": "", "value": obj.opacity, "min_val": 0.0, "max_val": 1.0}]}, + ] + items.extend(new_items) + + if not status: + return None + + return base_layout(items) + + +class ObjectSetting(QWidget): + """ + A QWidget to manage the settings of objects in the viewer. + + Parameters + ---------- + viewer : Viewer + The viewer instance containing the objects. + + Attributes + ---------- + viewer : Viewer + The viewer instance. + layout : QVBoxLayout + The main layout for the widget. + update_button : QPushButton + The button to trigger the object update. + spin_boxes : dict + Dictionary to hold spin boxes for object properties. + + Methods + ------- + clear_layout(layout) + Clears all widgets and sub-layouts from the given layout. + update() + Updates the layout with the latest object settings. + obj_update() + Applies the settings from spin boxes to the selected objects. + """ + + update_requested = Signal() + + def __init__(self, viewer: "Viewer"): + super().__init__() + self.viewer = viewer + self.layout = QVBoxLayout(self) + self.spin_boxes = {} + + def clear_layout(self, layout): + """Clear all widgets from the layout.""" + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + else: + sub_layout = item.layout() + if sub_layout is not None: + self.clear_layout(sub_layout) + + def update(self): + """Update the layout with the latest object settings.""" + self.clear_layout(self.layout) + output = object_setting_layout(self.viewer) + + if output is not None: + text = "Update Object" + obj_setting_layout, self.spin_boxes = output + self.layout.addLayout(obj_setting_layout) + self.update_button = QPushButton(text, self) + self.update_button.clicked.connect(self.obj_update) + self.layout.addWidget(self.update_button) + + def obj_update(self): + """Apply the settings from spin boxes to the selected objects.""" + for obj in self.viewer.scene.objects: + if obj.is_selected: + obj.linewidth = self.spin_boxes["Line_Width_"].spinbox.value() + obj.pointsize = self.spin_boxes["Point_Size_"].spinbox.value() + obj.opacity = self.spin_boxes["Opacity_"].spinbox.value() + obj.update() + + +class ObjectSettingDialog(QDialog, Base): + """ + A dialog for displaying and updating object settings in Qt applications. + This dialog allows users to modify object properties such as line width, point size, and opacity, + and applies these changes dynamically. + + Attributes + ---------- + layout : QVBoxLayout + The layout of the dialog. + spin_boxes : dict + Dictionary containing spin boxes for adjusting object properties. + update_button : QPushButton + Button to apply changes to the selected objects. + + Methods + ------- + update() + Updates the properties of selected objects and closes the dialog. + + Example + ------- + >>> dialog = ObjectInfoDialog() + >>> dialog.exec() + """ + + def __init__(self) -> None: + super().__init__() + + self.setWindowTitle("Object Settings") + self.layout = QVBoxLayout(self) + output = object_setting_layout(self.viewer) + + if output is not None: + text = "Update Object" + obj_setting_layout, self.spin_boxes = output + self.layout.addLayout(obj_setting_layout) + else: + text = "No object selected." + + self.update_button = QPushButton(text, self) + self.update_button.clicked.connect(self.obj_update) + self.layout.addWidget(self.update_button) + + def obj_update(self) -> None: + for obj in self.viewer.scene.objects: + if obj.is_selected: + obj.linewidth = self.spin_boxes["Line_Width_"].spinbox.value() + obj.pointsize = self.spin_boxes["Point_Size_"].spinbox.value() + obj.opacity = self.spin_boxes["Opacity_"].spinbox.value() + obj.update() + + self.accept() diff --git a/src/compas_viewer/components/widget_tools.py b/src/compas_viewer/components/widget_tools.py deleted file mode 100644 index 2ad1210788..0000000000 --- a/src/compas_viewer/components/widget_tools.py +++ /dev/null @@ -1,52 +0,0 @@ -from PySide6 import QtGui -from PySide6 import QtWidgets - - -class BaseWidgetFactory: - def __init__(self) -> None: - self.spacing: int = 8 - - def setup_layout(self, layout: QtWidgets.QHBoxLayout): - layout.setSpacing(self.spacing) - layout.setContentsMargins(0, 0, 0, 0) - - -class DoubleEditWidget(BaseWidgetFactory): - def __call__(self, label_name: str, value: float, minval: float, maxval: float) -> QtWidgets.QWidget: - widget = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout() - validator = QtGui.QDoubleValidator() - validator.setRange(minval, maxval) - label = QtWidgets.QLabel() - label.setText(label_name) - line_edit = QtWidgets.QLineEdit() - line_edit.setText(str(value)) - line_edit.setValidator(validator) - layout.addWidget(label) - layout.addWidget(line_edit) - self.setup_layout(layout) - widget.setLayout(layout) - return widget - - -class DoubleSpinnerWidget(BaseWidgetFactory): - def __call__(self, label_name: str, value: float, minval: float, maxval: float) -> QtWidgets.QWidget: - widget = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout() - spinner = QtWidgets.QSpinBox() - label = QtWidgets.QLabel(label_name) - spinner.setMinimum(minval) - spinner.setMaximum(maxval) - spinner.setValue(value) - layout.addWidget(label) - layout.addWidget(spinner) - self.setup_layout(layout) - widget.setLayout(layout) - return widget - - -class LabelWidget(BaseWidgetFactory): - def __call__(self, text: str) -> QtWidgets.QWidget: - label = QtWidgets.QLabel() - label.setText(text) - return label diff --git a/src/compas_viewer/config.py b/src/compas_viewer/config.py index f297a4ecc3..aa1f337206 100644 --- a/src/compas_viewer/config.py +++ b/src/compas_viewer/config.py @@ -16,6 +16,7 @@ from compas_viewer.commands import clear_scene_cmd from compas_viewer.commands import deselect_all_cmd from compas_viewer.commands import load_scene_cmd +from compas_viewer.commands import obj_settings_cmd from compas_viewer.commands import pan_view_cmd from compas_viewer.commands import rotate_view_cmd from compas_viewer.commands import save_scene_cmd @@ -191,6 +192,12 @@ class MenubarConfig(ConfigBase): }, ], }, + { + "title": "Info", + "items": [ + {"title": "Selected obj info", "action": obj_settings_cmd}, + ], + }, { "title": "Server/Session", "items": [], @@ -376,5 +383,6 @@ class Config(ConfigBase): toggle_toolbar_cmd, zoom_selected_cmd, zoom_view_cmd, + obj_settings_cmd, ] ) diff --git a/src/compas_viewer/scene/shapeobject.py b/src/compas_viewer/scene/shapeobject.py index 3bf2f5228a..778e3b4464 100644 --- a/src/compas_viewer/scene/shapeobject.py +++ b/src/compas_viewer/scene/shapeobject.py @@ -109,57 +109,57 @@ def _read_backfaces_data(self) -> ShaderDataType: def update_matrix(self): self._update_matrix() - def update(self, update_positions: bool = True, update_colors: bool = True, update_elements: bool = True): - """Update the object. - - Parameters - ---------- - update_positions : bool, optional - Whether to update positions of the object. - update_colors : bool, optional - Whether to update colors of the object. - update_elements : bool, optional - Whether to update elements of the object. - """ - - # # Update the matrix from object's translation, rotation and scale. - # self._update_matrix() - - # self._points_data = self._read_points_data() - # self._lines_data = self._read_lines_data() - # self._frontfaces_data = self._read_frontfaces_data() - # self._backfaces_data = self._read_backfaces_data() - - # # Update all buffers from object's data. - # if self._points_data is not None: - # self.update_buffer_from_data( - # self._points_data, - # self._points_buffer, - # update_positions, - # update_colors, - # update_elements, - # ) - # if self._lines_data is not None: - # self.update_buffer_from_data( - # self._lines_data, - # self._lines_buffer, - # update_positions, - # update_colors, - # update_elements, - # ) - # if self._frontfaces_data is not None: - # self.update_buffer_from_data( - # self._frontfaces_data, - # self._frontfaces_buffer, - # update_positions, - # update_colors, - # update_elements, - # ) - # if self._backfaces_data is not None: - # self.update_buffer_from_data( - # self._backfaces_data, - # self._backfaces_buffer, - # update_positions, - # update_colors, - # update_elements, - # ) + # def update(self, update_positions: bool = True, update_colors: bool = True, update_elements: bool = True): + # """Update the object. + + # Parameters + # ---------- + # update_positions : bool, optional + # Whether to update positions of the object. + # update_colors : bool, optional + # Whether to update colors of the object. + # update_elements : bool, optional + # Whether to update elements of the object. + # """ + + # # Update the matrix from object's translation, rotation and scale. + # self._update_matrix() + + # self._points_data = self._read_points_data() + # self._lines_data = self._read_lines_data() + # self._frontfaces_data = self._read_frontfaces_data() + # self._backfaces_data = self._read_backfaces_data() + + # # Update all buffers from object's data. + # if self._points_data is not None: + # self.update_buffer_from_data( + # self._points_data, + # self._points_buffer, + # update_positions, + # update_colors, + # update_elements, + # ) + # if self._lines_data is not None: + # self.update_buffer_from_data( + # self._lines_data, + # self._lines_buffer, + # update_positions, + # update_colors, + # update_elements, + # ) + # if self._frontfaces_data is not None: + # self.update_buffer_from_data( + # self._frontfaces_data, + # self._frontfaces_buffer, + # update_positions, + # update_colors, + # update_elements, + # ) + # if self._backfaces_data is not None: + # self.update_buffer_from_data( + # self._backfaces_data, + # self._backfaces_buffer, + # update_positions, + # update_colors, + # update_elements, + # ) diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index d93d7b9017..49a4efbc95 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -3,10 +3,16 @@ from PySide6 import QtCore from PySide6 import QtWidgets +from compas_viewer.components.objectsetting import ObjectSetting + if TYPE_CHECKING: from .ui import UI +def is_layout_empty(layout): + return layout.count() == 0 + + class SideBarRight: def __init__(self, ui: "UI", show: bool = True) -> None: self.ui = ui @@ -18,6 +24,11 @@ def update(self): self.widget.update() for widget in self.widget.children(): widget.update() + if isinstance(widget, ObjectSetting): + if is_layout_empty(widget.layout): + widget.hide() + else: + widget.show() @property def show(self): diff --git a/src/compas_viewer/ui/statusbar.py b/src/compas_viewer/ui/statusbar.py index 905a0ba43c..7d7ae6a126 100644 --- a/src/compas_viewer/ui/statusbar.py +++ b/src/compas_viewer/ui/statusbar.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from compas_viewer.components.widget_tools import LabelWidget +from compas_viewer.components.label import LabelWidget if TYPE_CHECKING: from .ui import UI @@ -10,8 +10,7 @@ class SatusBar: def __init__(self, ui: "UI", show: bool = True) -> None: self.ui = ui self.widget = self.ui.window.widget.statusBar() - self.label = LabelWidget() - self.widget.addWidget(self.label(text="Ready...")) + self.widget.addWidget(LabelWidget(text="Ready...")) self.show = show @property diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index acddeae49e..9db17d7d01 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING from compas_viewer.components import Sceneform +from compas_viewer.components.objectsetting import ObjectSetting from .mainwindow import MainWindow from .menubar import MenuBar @@ -55,6 +56,8 @@ def __init__(self, viewer: "Viewer") -> None: }, ) ) + # TODO: Add ObjectSetting widget to config + self.sidebar.widget.addWidget(ObjectSetting(self.viewer)) self.window.widget.setCentralWidget(self.viewport.widget) self.window.widget.addDockWidget(SideDock.locations["left"], self.sidedock.widget)