diff --git a/doc/changelog.d/466.miscellaneous.md b/doc/changelog.d/466.miscellaneous.md new file mode 100644 index 000000000..664bf6f63 --- /dev/null +++ b/doc/changelog.d/466.miscellaneous.md @@ -0,0 +1 @@ +Feat: more customization APIs diff --git a/examples/00-basic-pyvista-examples/customization_api.py b/examples/00-basic-pyvista-examples/customization_api.py index b550370e5..62a7c2e16 100644 --- a/examples/00-basic-pyvista-examples/customization_api.py +++ b/examples/00-basic-pyvista-examples/customization_api.py @@ -83,12 +83,36 @@ # Scene title at the top center -plotter.add_text("Customization API Example", position=(0.5, 0.95), font_size=18, color='white') +plotter.add_text("Customization API Example", position="upper_edge", font_size=18, color='black') -# Additional labels at the top corners -plotter.add_text("Plotly Backend", position=(0.05, 0.95), font_size=12, color='lightblue') +# Additional labels at the top left corner using a string for the position as before +plotter.add_text("PyVista Backend", position="upper_left", font_size=12, color='lightblue') +# Additional labels at the bottom left corner using pixel coordinates plotter.add_text("3D Visualization", position=(0.95, 0.95), font_size=12, color='lightgreen') + +# Add labels at specific 3D points to annotate key locations in space. + +label_points = [ + [1, 0, 0], # X axis endpoint + [0, 1, 0], # Y axis endpoint + [0, 0, 1], # Z axis endpoint +] + +labels = ['X-axis', 'Y-axis', 'Z-axis'] + +plotter.add_labels(label_points, labels, font_size=16, point_size=8.0) + + +# The clear() method resets the plotter and can be called even after show(). +# This allows reusing the same plotter for multiple visualizations. + +# Uncomment to clear everything added above and start fresh: +# plotter.show() +# plotter.clear() +# plotter.plot(pv.Cube()) # Would show only a cube instead + + # Display the visualization with all customizations. plotter.show() diff --git a/examples/01-basic-plotly-examples/customization_api.py b/examples/01-basic-plotly-examples/customization_api.py index e369e2151..ea2a059f0 100644 --- a/examples/01-basic-plotly-examples/customization_api.py +++ b/examples/01-basic-plotly-examples/customization_api.py @@ -86,13 +86,35 @@ # Add text annotations using 2D normalized coordinates (0-1 range). # Scene title at the top center -plotter.add_text("Customization API Example", position=(0.5, 0.95), font_size=18, color='white') +plotter.add_text("Customization API Example", position=(0.5, 0.95), font_size=18, color='black') # Additional labels at the top corners plotter.add_text("Plotly Backend", position=(0.05, 0.95), font_size=12, color='lightblue') plotter.add_text("3D Visualization", position=(0.95, 0.95), font_size=12, color='lightgreen') +# Add labels at specific 3D points to annotate key locations in space. + +label_points = [ + [1, 0, 0], # X axis endpoint + [0, 1, 0], # Y axis endpoint + [0, 0, 1], # Z axis endpoint +] + +labels = ['X-axis', 'Y-axis', 'Z-axis'] + +plotter.add_labels(label_points, labels, font_size=16, point_size=8.0) + + +# The clear() method resets the plotter and can be called even after show(). +# This allows reusing the same plotter for multiple visualizations. + +# Uncomment to clear everything added above and start fresh: +# plotter.show() +# plotter.clear() +# plotter.plot(pv.Cube()) # Would show only a cube instead + + # Display the visualization with all customizations. diff --git a/src/ansys/tools/visualization_interface/backends/_base.py b/src/ansys/tools/visualization_interface/backends/_base.py index 8698877e0..de920fe5b 100644 --- a/src/ansys/tools/visualization_interface/backends/_base.py +++ b/src/ansys/tools/visualization_interface/backends/_base.py @@ -170,3 +170,54 @@ def add_text( Backend-specific actor or object representing the added text. """ raise NotImplementedError("add_text method must be implemented") + + @abstractmethod + def add_labels( + self, + points: Union[List, Any], + labels: List[str], + font_size: int = 12, + point_size: float = 5.0, + **kwargs + ) -> Any: + """Add labels at 3D point locations. + + Parameters + ---------- + points : Union[List, Any] + Points where labels should be placed. Can be a list of coordinates + or array-like object. Expected format: [[x1, y1, z1], ...] or Nx3 array. + labels : List[str] + List of label strings to display at each point. + font_size : int, default: 12 + Font size for the labels. + point_size : float, default: 5.0 + Size of the point markers shown with labels. + **kwargs : dict + Additional backend-specific keyword arguments. + + Returns + ------- + Any + Backend-specific actor or object representing the added labels. + """ + raise NotImplementedError("add_labels method must be implemented") + + @abstractmethod + def clear(self) -> None: + """Clear all actors from the scene. + + This method removes all previously added objects (meshes, points, lines, + text, etc.) from the visualization scene, therefore allowing + the plotter to be reused after ``show()`` has been called. + + Notes + ----- + Both PyVista and Plotly backends support clearing and reusing the + plotter after ``show()`` has been called. This enables workflows like: + + 1. Add objects and call ``show()`` + 2. Call ``clear()`` to reset + 3. Add new objects and call ``show()`` again + """ + raise NotImplementedError("clear method must be implemented") diff --git a/src/ansys/tools/visualization_interface/backends/plotly/plotly_interface.py b/src/ansys/tools/visualization_interface/backends/plotly/plotly_interface.py index 6cd07478f..f4c134fa7 100644 --- a/src/ansys/tools/visualization_interface/backends/plotly/plotly_interface.py +++ b/src/ansys/tools/visualization_interface/backends/plotly/plotly_interface.py @@ -494,3 +494,55 @@ def add_text( ) self._fig.add_annotation(annotation) return annotation + + def add_labels( + self, + points: Union[List, Any], + labels: List[str], + font_size: int = 12, + point_size: float = 5.0, + **kwargs + ) -> Any: + """Add labels at 3D point locations. + + Parameters + ---------- + points : Union[List, Any] + Points where labels should be placed. + labels : List[str] + List of label strings to display at each point. + font_size : int, default: 12 + Font size for the labels. + point_size : float, default: 5.0 + Size of the point markers shown with labels. + **kwargs : dict + Additional keyword arguments. + + Returns + ------- + Any + Plotly trace representing the labels. + """ + import numpy as np + + points_array = np.asarray(points) + if points_array.ndim == 1: + points_array = points_array.reshape(-1, 3) + + # Create a scatter trace with both markers and text + trace = go.Scatter3d( + x=points_array[:, 0], + y=points_array[:, 1], + z=points_array[:, 2], + mode='markers+text', + text=labels, + textfont=dict(size=font_size), + marker=dict(size=point_size), + **kwargs + ) + self._fig.add_trace(trace) + return trace + + def clear(self) -> None: + """Clear all traces from the figure.""" + self._fig.data = [] diff --git a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py index a36ca7405..83ce09cef 100644 --- a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py +++ b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py @@ -21,7 +21,6 @@ # SOFTWARE. """Provides a wrapper to aid in plotting.""" from abc import abstractmethod -from collections.abc import Callable import importlib.util from typing import Any, Dict, List, Optional, Union @@ -58,6 +57,10 @@ ) from ansys.tools.visualization_interface.backends.pyvista.widgets.widget import PlotterWidget from ansys.tools.visualization_interface.types.edge_plot import EdgePlot +from ansys.tools.visualization_interface.utils._kwargs_manager import ( + _capture_init_params, + _extract_kwargs, +) from ansys.tools.visualization_interface.utils.color import Color from ansys.tools.visualization_interface.utils.logger import logger @@ -122,6 +125,9 @@ def __init__( from vtkmodules.vtkInteractionWidgets import vtkHoverWidget from vtkmodules.vtkRenderingCore import vtkPointPicker + # Save initialization parameters for potential reinitialization via clear() + self._init_params = _capture_init_params(self.__init__, locals()) + # Check if the use of trame was requested if use_trame is None: use_trame = ansys.tools.visualization_interface.USE_TRAME @@ -184,6 +190,40 @@ def __init__( else: raise TypeError("custom_picker must be an instance of AbstractPicker.") + def _cleanup(self) -> None: + """Clean up resources before reinitialization. + + This method releases VTK resources, disables widgets and observers, + and closes the existing plotter. + """ + # Disable hover widget if active + if self._hover_widget is not None: + try: + self._hover_widget.EnabledOff() + except Exception: + logger.warning("Failed to disable hover widget. It may not be active.") + + # Disable picking if it was enabled + if self._allow_picking and self._pl is not None: + try: + self._pl.scene.disable_picking() + except Exception: + logger.warning("Failed to disable picking. It may not be active.") + + # Clear widgets list + self._widgets.clear() + + # Clear object-to-actor maps + self._object_to_actors_map.clear() + self._edge_actors_map.clear() + + # Close the existing PyVista plotter to release VTK resources + if self._pl is not None: + try: + self._pl.scene.close() + except Exception: + logger.warning("Failed to close the plotter. It may already be closed.") + @property def pv_interface(self) -> PyVistaInterface: """PyVista interface.""" @@ -370,31 +410,6 @@ def disable_center_focus(self): self._pl.scene.disable_picking() self._picked_ball.SetVisibility(False) - def __extract_kwargs(self, func_name: Callable, input_kwargs: Dict[str, Any]) -> Dict[str, Any]: - """Extracts the keyword arguments from a function signature and returns it as dict. - - Parameters - ---------- - func_name : Callable - Function to extract the keyword arguments from. It should be a callable function - input_kwargs : Dict[str, Any] - Dictionary with the keyword arguments to update the extracted ones. - - Returns - ------- - Dict[str, Any] - Dictionary with the keyword arguments extracted from the function signature and - updated with the input kwargs. - """ - import inspect - signature = inspect.signature(func_name) - kwargs = {} - for k, v in signature.parameters.items(): - # We are ignoring positional arguments, and passing everything as kwarg - if v.default is not inspect.Parameter.empty: - kwargs[k] = input_kwargs[k] if k in input_kwargs else v.default - return kwargs - def show( self, plottable_object: Any = None, @@ -430,11 +445,11 @@ def show( List with the picked bodies in the picked order. """ - plotting_options = self.__extract_kwargs( + plotting_options = _extract_kwargs( self._pl._scene.add_mesh, kwargs, ) - show_options = self.__extract_kwargs( + show_options = _extract_kwargs( self._pl.scene.show, kwargs, ) @@ -599,6 +614,9 @@ def __init__( **plotter_kwargs, ) + # Save initialization parameters for reinitialization via clear() + self._init_params = _capture_init_params(self.__init__, locals()) + @property def base_plotter(self): """Return the base plotter object.""" @@ -785,7 +803,6 @@ def add_points( point_cloud, color=color, point_size=size, - render_points_as_spheres=True, **kwargs ) @@ -952,3 +969,67 @@ def add_text( ) return actor + + def add_labels( + self, + points: Union[List, Any], + labels: List[str], + font_size: int = 12, + point_size: float = 5.0, + **kwargs + ) -> "pv.Actor": + """Add labels at 3D point locations. + + Parameters + ---------- + points : Union[List, Any] + Points where labels should be placed. Can be a list of coordinates + or array-like object. Expected format: [[x1, y1, z1], ...] or Nx3 array. + labels : List[str] + List of label strings to display at each point. + font_size : int, default: 12 + Font size for the labels. + point_size : float, default: 5.0 + Size of the point markers shown with labels. + **kwargs : dict + Additional keyword arguments passed to PyVista's add_point_labels method. + + Returns + ------- + pv.Actor + PyVista actor representing the added labels. + """ + import numpy as np + + # Convert points to numpy array if needed + points_array = np.asarray(points) + + # Ensure points are 2D with shape (N, 3) + if points_array.ndim == 1: + points_array = points_array.reshape(-1, 3) + + # Create PyVista PolyData from points + point_cloud = pv.PolyData(points_array) + + # Add point labels to the scene + actor = self._pl.scene.add_point_labels( + point_cloud, + labels, + font_size=font_size, + point_size=point_size, + **kwargs + ) + + return actor + + def clear(self) -> None: + """Clear all actors from the scene and reset the plotter. + + This method removes all previously added objects (meshes, points, lines, + text, etc.) from the visualization scene by fully reinitializing the + plotter. + """ + # Clean up existing resources + self._cleanup() + # Reinitialize with saved parameters + self.__init__(**self._init_params) diff --git a/src/ansys/tools/visualization_interface/plotter.py b/src/ansys/tools/visualization_interface/plotter.py index 14dac59c1..81c05af82 100644 --- a/src/ansys/tools/visualization_interface/plotter.py +++ b/src/ansys/tools/visualization_interface/plotter.py @@ -421,7 +421,6 @@ def add_text( - 2D tuple (x, y) for screen/viewport coordinates (pixels from bottom-left) - String position like 'upper_left', 'upper_right', 'lower_left', 'lower_right', 'upper_edge', 'lower_edge' (backend-dependent support) - font_size : int, default: 12 Font size for the text in points. color : str, default: "white" @@ -458,3 +457,96 @@ def add_text( return self._backend.add_text( text=text, position=position, font_size=font_size, color=color, **kwargs ) + + def add_labels( + self, + points: Union[List, Any], + labels: List[str], + font_size: int = 12, + point_size: float = 5.0, + **kwargs + ) -> Any: + """Add labels at 3D point locations. + + This method provides a backend-agnostic way to add text labels at + specific 3D coordinates in the visualization scene. Labels are + displayed next to marker points. + + Parameters + ---------- + points : Union[List, Any] + Points where labels should be placed. Can be a list of coordinates + or array-like object. Expected format: [[x1, y1, z1], ...] or Nx3 array. + labels : List[str] + List of label strings to display at each point. Must have the same + length as points. + font_size : int, default: 12 + Font size for the labels. + point_size : float, default: 5.0 + Size of the point markers shown with labels. + **kwargs : dict + Additional backend-specific keyword arguments for advanced customization + (e.g., text_color, shape, fill_shape). + + Returns + ------- + Any + Backend-specific actor or object representing the added labels. + Can be used for further manipulation or removal. + + Examples + -------- + Add labels at specific locations: + + >>> from ansys.tools.visualization_interface import Plotter + >>> plotter = Plotter() + >>> points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + >>> labels = ['Origin', 'X-axis', 'Y-axis'] + >>> plotter.add_labels(points, labels, font_size=14) + >>> plotter.show() + + Add labels with custom styling: + + >>> plotter.add_labels( + ... points, + ... labels, + ... font_size=16, + ... point_size=10, + ... text_color='yellow', + ... shape='rounded_rect' + ... ) + >>> plotter.show() + """ + return self._backend.add_labels( + points=points, labels=labels, font_size=font_size, point_size=point_size, **kwargs + ) + + def clear(self) -> None: + """Clear all actors from the scene. + + This method removes all previously added objects (meshes, points, lines, + text, etc.) from the visualization scene, therefore allowing + the plotter to be reused after ``show()`` has been called. + + Examples + -------- + Clear before showing: + + >>> from ansys.tools.visualization_interface import Plotter + >>> import pyvista as pv + >>> plotter = Plotter() + >>> plotter.plot(pv.Sphere()) + >>> plotter.clear() # Changed mind, remove sphere + >>> plotter.plot(pv.Cube()) + >>> plotter.show() + + Clear after showing + + >>> plotter = Plotter() + >>> plotter.plot(pv.Sphere()) + >>> plotter.show() + >>> plotter.clear() # Reset the plotter to reuse it + >>> plotter.plot(pv.Cube()) + >>> plotter.show() + """ + self._backend.clear() diff --git a/src/ansys/tools/visualization_interface/utils/_kwargs_manager.py b/src/ansys/tools/visualization_interface/utils/_kwargs_manager.py new file mode 100644 index 000000000..48e94fd2d --- /dev/null +++ b/src/ansys/tools/visualization_interface/utils/_kwargs_manager.py @@ -0,0 +1,146 @@ +# Copyright (C) 2024 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Utilities for filtering and extracting keyword arguments.""" +import inspect +from typing import Any, Callable, Dict, List, Optional + + +def _extract_kwargs(func: Callable, input_kwargs: Dict[str, Any]) -> Dict[str, Any]: + """Extract keyword arguments that match a function's signature. + + This function inspects the signature of a callable and returns a dictionary + containing only the keyword arguments that the function accepts. For each + parameter with a default value, it uses the value from ``input_kwargs`` if + present, otherwise it uses the parameter's default value. + + Parameters + ---------- + func : Callable + Function to extract the keyword arguments from. + input_kwargs : Dict[str, Any] + Dictionary with keyword arguments to filter. + + Returns + ------- + Dict[str, Any] + Dictionary containing only the keyword arguments that match the + function's signature, with values from ``input_kwargs`` or defaults. + + Notes + ----- + - Only parameters with default values are included in the output. + - Positional-only parameters without defaults are ignored. + - This is useful for filtering kwargs before passing to PyVista functions. + + Examples + -------- + >>> def my_func(a, b=1, c=2): + ... pass + >>> _extract_kwargs(my_func, {"b": 10, "d": 20}) + {"b": 10, "c": 2} + """ + signature = inspect.signature(func) + kwargs = {} + for k, v in signature.parameters.items(): + # We are ignoring positional arguments, and passing everything as kwarg + if v.default is not inspect.Parameter.empty: + kwargs[k] = input_kwargs[k] if k in input_kwargs else v.default + return kwargs + + +def _capture_init_params( + func_or_method: Callable, + locals_dict: Dict[str, Any], + exclude: Optional[List[str]] = None, +) -> Dict[str, Any]: + """Capture initialization parameters from a function's locals for reinitialization. + + This function dynamically extracts all parameters passed to an ``__init__`` method + by inspecting its signature and reading values from the ``locals()`` dictionary. + This is particularly useful for implementing reinitialization patterns where the + exact initialization parameters need to be saved and replayed later. + + Parameters + ---------- + func_or_method : Callable + The function or method whose parameters should be captured. Typically + ``self.__init__`` or the class's ``__init__`` method. + locals_dict : Dict[str, Any] + The ``locals()`` dictionary from inside the ``__init__`` method. This + contains the actual values of all parameters at the point of capture. + exclude : Optional[List[str]], default: None + List of parameter names to exclude from the result. Common exclusions + are ``'self'`` and ``'cls'``, though ``'self'`` is automatically excluded. + + Returns + ------- + Dict[str, Any] + Dictionary mapping parameter names to their values, suitable for unpacking + with ``**`` when calling the function. If the signature includes ``**kwargs``, + those are flattened into the result dictionary. + + Examples + -------- + Using in a child class after super(): + + >>> class Parent: + ... def __init__(self, x, y): + ... self.x = x + ... self.y = y + ... + >>> class Child(Parent): + ... def __init__(self, x, y, z): + ... super().__init__(x, y) + ... # Capture after super() to avoid interference + ... self._init_params = _capture_init_params( + ... self.__init__, + ... locals() + ... ) + ... + >>> obj = Child(1, 2, 3) + >>> obj._init_params + {'x': 1, 'y': 2, 'z': 3} + """ + if exclude is None: + exclude = [] + + exclude_set = set(exclude) | {'self', 'cls'} + sig = inspect.signature(func_or_method) + result = {} + + for param_name, param in sig.parameters.items(): + if param_name in exclude_set: + continue + if param_name not in locals_dict: + continue + + # Handle VAR_KEYWORD (**kwargs) parameters + if param.kind == inspect.Parameter.VAR_KEYWORD: + # Flatten the kwargs into the result + kwargs_value = locals_dict[param_name] + if isinstance(kwargs_value, dict): + result.update(kwargs_value) + else: + # Regular parameter - add directly + result[param_name] = locals_dict[param_name] + + return result diff --git a/tests/test_customization_api.py b/tests/test_customization_api.py index 697356fcb..879d34ebf 100644 --- a/tests/test_customization_api.py +++ b/tests/test_customization_api.py @@ -21,6 +21,8 @@ # SOFTWARE. """Test module for the customization API methods.""" +import pyvista as pv + from ansys.tools.visualization_interface import Plotter from ansys.tools.visualization_interface.backends.plotly.plotly_interface import PlotlyBackend @@ -128,3 +130,70 @@ def test_add_text_plotly(): pl = Plotter(backend=PlotlyBackend()) annotation = pl.add_text("Test Label", position=(0.5, 0.9), font_size=14, color="yellow") assert annotation is not None + + +def test_add_labels_pyvista(): + """Test add_labels API with PyVista backend.""" + pl = Plotter() + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + labels = ['Point A', 'Point B', 'Point C'] + actor = pl.add_labels(points, labels, font_size=14, point_size=8.0) + assert actor is not None + pl.show() + + +def test_add_labels_plotly(): + """Test add_labels API with Plotly backend.""" + pl = Plotter(backend=PlotlyBackend()) + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + labels = ['Point A', 'Point B', 'Point C'] + trace = pl.add_labels(points, labels, font_size=14, point_size=8.0) + assert trace is not None + + +def test_clear_pyvista_before_show(): + """Test clear API with PyVista backend - clear before show.""" + pl = Plotter() + # Add multiple objects + sphere = pv.Sphere() + pl.plot(sphere) + pl.add_points([[0, 0, 0], [1, 0, 0]], color='red', size=10) + pl.add_lines([[0, 0, 0], [1, 1, 1]], color='blue', width=2.0) + # Clear before show + pl.clear() + # Add new object to verify plotter still works + cube = pv.Cube() + pl.plot(cube) + pl.show() + + +def test_clear_pyvista_after_show(): + """Test clear API with PyVista backend - clear after show (reinitialization).""" + pl = Plotter() + # Add and show first object + sphere = pv.Sphere() + pl.plot(sphere) + pl.show() + # Clear after show - this should reinitialize the plotter + pl.clear() + # Add new object to verify plotter can be reused + cube = pv.Cube() + pl.plot(cube) + pl.show() + + +def test_clear_plotly(): + """Test clear API with Plotly backend.""" + pl = Plotter(backend=PlotlyBackend()) + # Add multiple objects + sphere = pv.Sphere() + pl.plot(sphere) + pl.add_points([[0, 0, 0], [1, 0, 0]], color='red', size=10) + pl.add_lines([[0, 0, 0], [1, 1, 1]], color='blue', width=2.0) + # Clear the scene + pl.clear() + # Verify all traces removed + assert len(pl._backend._fig.data) == 0 + # Add new object to verify plotter still works + cube = pv.Cube() + pl.plot(cube)