diff --git a/CHANGELOG.md b/CHANGELOG.md index b3758c9aae..204a816752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +### Changed + +### Removed + + +## [1.2.4] 2024-07-12 + +### Added + * Added `list` to accepted types for `Scene.add`. * Added `list[float]` to accepted types for `Camera.position` and `Camera.target`. +* Added `unit` to `Viewer` and `Config`. +* Added `bounding_box` and `_update_bounding_box` to `BufferObject`. * Added `robot.py` example. ### Changed * Fixed `opacity` bug with `BufferObject`. +* Updated `SceneForm` to avoid completely reload when scene objects not changed. ### Removed diff --git a/pyproject.toml b/pyproject.toml index 234b8e8935..b0fee498a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ doctest_optionflags = [ # ============================================================================ [tool.bumpversion] -current_version = "1.2.3" +current_version = "1.2.4" message = "Bump version to {new_version}" commit = true tag = true diff --git a/scripts/forms.py b/scripts/forms.py deleted file mode 100644 index 4baca9b050..0000000000 --- a/scripts/forms.py +++ /dev/null @@ -1,39 +0,0 @@ -import compas -from compas.colors import Color -from compas.datastructures import Mesh -from compas.geometry import Box -from compas.geometry import Frame -from compas_viewer.components import Sceneform -from compas_viewer.config import Config -from compas_viewer.viewer import Viewer - -config = Config() -config.ui.sidebar.sceneform = False -config.ui.sidedock.show = False -config.renderer.view = "top" - -viewer = Viewer(config=config) - -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()) - -N = 10 -M = 10 - -for i in range(N): - for j in range(M): - viewer.scene.add( - Box(0.5, 0.5, 0.5, Frame([i, j, 0], [1, 0, 0], [0, 1, 0])), - linecolor=Color.white(), - facecolor=Color(i / N, j / M, 0.0), - name=f"Box_{i}_{j}", - ) - - -def callback(item): - print("Callback triggered on", item) - - -viewer.ui.sidebar.widget.addWidget(Sceneform(viewer.scene, {"Name": (lambda o: o.name)}, callback=callback)) - -viewer.show() diff --git a/scripts/unit.py b/scripts/unit.py new file mode 100644 index 0000000000..b262a933fd --- /dev/null +++ b/scripts/unit.py @@ -0,0 +1,20 @@ +from compas.colors import Color +from compas.geometry import Box +from compas.geometry import Frame +from compas_viewer.viewer import Viewer +from compas_viewer.config import Config + +config = Config() +config.unit = "mm" +viewer = Viewer(config) + +for i in range(10): + for j in range(10): + viewer.scene.add( + Box(500, 500, 500, Frame([i * 1000, j * 1000, 0], [1, 0, 0], [0, 1, 0])), + show_lines=True, + surfacecolor=Color(i / 10, j / 10, 0.0), + name=f"Box_{i}_{j}", + ) + +viewer.show() diff --git a/src/compas_viewer/__init__.py b/src/compas_viewer/__init__.py index 76f903cd3b..dd708bbef6 100644 --- a/src/compas_viewer/__init__.py +++ b/src/compas_viewer/__init__.py @@ -21,7 +21,7 @@ __copyright__ = "COMPAS Association" __license__ = "MIT License" __email__ = "li.chen@arch.ethz.ch" -__version__ = "1.2.3" +__version__ = "1.2.4" HERE = os.path.dirname(__file__) diff --git a/src/compas_viewer/components/sceneform.py b/src/compas_viewer/components/sceneform.py index a352d60e6a..55dee93de1 100644 --- a/src/compas_viewer/components/sceneform.py +++ b/src/compas_viewer/components/sceneform.py @@ -48,6 +48,7 @@ def __init__( self.setColumnCount(len(columns)) self.setHeaderLabels(col["title"] for col in self.columns) self.setHeaderHidden(not show_headers) + self._sceneobjects = [] self.callback = callback @@ -65,52 +66,62 @@ def scene(self): return self.viewer.scene def update(self): - self.clear() # TODO: do not clear when objects are same. - self.checkbox_columns = {} - - for node in self.scene.traverse("breadthfirst"): - if node.is_root: - continue - - strings = [] - - for i, column in enumerate(self.columns): - type = column.get("type", None) - if type == "checkbox": - action = column.get("action") - checked = column.get("checked") - if not action or not checked: - raise ValueError("Both action and checked must be provided for checkbox") - self.checkbox_columns[i] = {"action": action, "checked": checked} - strings.append("") - elif type == "label": - text = column.get("text") - if not text: - raise ValueError("Text must be provided for label") - strings.append(text(node)) - - parent_widget = self if node.parent.is_root else node.parent.attributes["widget"] - widget = QTreeWidgetItem(parent_widget, strings) - widget.node = node - widget.setSelected(node.is_selected) - if node.is_selected: - - def expand(node): - if node.attributes.get("widget"): - node.attributes["widget"].setExpanded(True) - if node.parent and not node.parent.is_root: - expand(node.parent) - - expand(node.parent) - - widget.setFlags(widget.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled) - - for col, col_data in self.checkbox_columns.items(): - widget.setCheckState(col, Qt.Checked if col_data["checked"](node) else Qt.Unchecked) - - node.attributes["widget"] = widget - - self.adjust_column_widths() + if list(self.scene.objects) == self._sceneobjects: + for node in self.scene.traverse("breadthfirst"): + widget = node.attributes.get("widget") + if widget: + widget.setSelected(node.is_selected) + if node.is_selected: + self.expand(node.parent) + + else: + self._sceneobjects = list(self.scene.objects) + + self.clear() + self.checkbox_columns = {} + + for node in self.scene.traverse("breadthfirst"): + if node.is_root: + continue + + strings = [] + + for i, column in enumerate(self.columns): + type = column.get("type", None) + if type == "checkbox": + action = column.get("action") + checked = column.get("checked") + if not action or not checked: + raise ValueError("Both action and checked must be provided for checkbox") + self.checkbox_columns[i] = {"action": action, "checked": checked} + strings.append("") + elif type == "label": + text = column.get("text") + if not text: + raise ValueError("Text must be provided for label") + strings.append(text(node)) + + parent_widget = self if node.parent.is_root else node.parent.attributes["widget"] + widget = QTreeWidgetItem(parent_widget, strings) + widget.node = node + widget.setSelected(node.is_selected) + if node.is_selected: + self.expand(node.parent) + + widget.setFlags(widget.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + for col, col_data in self.checkbox_columns.items(): + widget.setCheckState(col, Qt.Checked if col_data["checked"](node) else Qt.Unchecked) + + node.attributes["widget"] = widget + + self.adjust_column_widths() + + def expand(self, node): + if node.attributes.get("widget"): + node.attributes["widget"].setExpanded(True) + if node.parent and not node.parent.is_root: + self.expand(node.parent) def on_item_clicked(self, item, column): if column in self.checkbox_columns: diff --git a/src/compas_viewer/config.py b/src/compas_viewer/config.py index fcf5c06ca2..3c4da0fc26 100644 --- a/src/compas_viewer/config.py +++ b/src/compas_viewer/config.py @@ -369,6 +369,7 @@ class UIConfig(ConfigBase): @dataclass class Config(ConfigBase): vectorsize: float = 0.1 + unit: Literal["m", "cm", "mm"] = "m" ui: UIConfig = field(default_factory=UIConfig) window: WindowConfig = field(default_factory=WindowConfig) renderer: RendererConfig = field(default_factory=RendererConfig) diff --git a/src/compas_viewer/scene/bufferobject.py b/src/compas_viewer/scene/bufferobject.py index c9cd7c8ec2..6db6dada70 100644 --- a/src/compas_viewer/scene/bufferobject.py +++ b/src/compas_viewer/scene/bufferobject.py @@ -211,18 +211,30 @@ def __init__( self.is_selected = False self.background = False self._matrix_buffer = None + self._bounding_box = None self._bounding_box_center = None @property def buffergeometry(self) -> BufferGeometry: return self.item + @property + def bounding_box(self) -> NDArray: + if self._bounding_box is None: + self._bounding_box = np.array([np.min(self.buffergeometry.points, axis=0), np.max(self.buffergeometry.points, axis=0)]) + return self._bounding_box + @property def bounding_box_center(self) -> NDArray: if self._bounding_box_center is None: self._bounding_box_center = np.mean(self.buffergeometry.points.reshape(-1, 3), axis=0) return self._bounding_box_center + def _update_bounding_box(self): + self._bounding_box = None + self._bounding_box_center = None + # Set to None so that they are recalculated next time they are accessed + def init(self): """Initialize the object""" self.instance_color = Color.from_rgb255(*next(self.scene._instance_colors_generator)) diff --git a/src/compas_viewer/viewer.py b/src/compas_viewer/viewer.py index 04e763550d..117d9d0381 100644 --- a/src/compas_viewer/viewer.py +++ b/src/compas_viewer/viewer.py @@ -20,12 +20,14 @@ class Viewer(Singleton): def __init__(self, config: Optional[Config] = None, **kwargs): + self.running = False self.app = QApplication(sys.argv) self.app.setApplicationName("COMPAS Viewer") self.app.setApplicationDisplayName("COMPAS Viewer") self.app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "icons", "compas_icon_white.png"))) self._scene = None + self._unit = "m" self.config = config or Config() self.timer = QTimer() @@ -36,8 +38,7 @@ def __init__(self, config: Optional[Config] = None, **kwargs): # renderer should be part of UI self.renderer = Renderer(self) self.ui = UI(self) - - self.running = False + self.unit = self.config.unit @property def scene(self) -> ViewerScene: @@ -52,6 +53,31 @@ def scene(self, scene: Scene): for obj in self._scene.objects: obj.init() + @property + def unit(self) -> str: + return self._unit + + @unit.setter + def unit(self, unit: str): + if self.running: + raise NotImplementedError("Changing the unit after the viewer is running is not yet supported.") + if unit != self._unit: + previous_scale = self.config.camera.scale + if unit == "m": + self.config.renderer.gridsize = (10.0, 10, 10.0, 10) + self.renderer.camera.scale = 1.0 + elif unit == "cm": + self.config.renderer.gridsize = (1000.0, 10, 1000.0, 10) + self.renderer.camera.scale = 100.0 + elif unit == "mm": + self.config.renderer.gridsize = (10000.0, 10, 10000.0, 10) + self.renderer.camera.scale = 1000.0 + else: + raise ValueError(f"Invalid unit: {unit}. Valid units are 'm', 'cm', 'mm'.") + self.renderer.camera.distance *= self.renderer.camera.scale / previous_scale + + self._unit = unit + def show(self): self.running = True self.ui.init()