diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index a4fb27459..6a57fbc7f 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -11,7 +11,7 @@ on: env: MAIN_PYTHON_VERSION: '3.13' - RESET_IMAGE_CACHE: 1 + RESET_IMAGE_CACHE: 2 PACKAGE_NAME: ansys-tools-visualization-interface DOCUMENTATION_CNAME: visualization-interface.tools.docs.pyansys.com IN_GITHUB_ACTIONS: true diff --git a/doc/changelog.d/465.maintenance.md b/doc/changelog.d/465.maintenance.md new file mode 100644 index 000000000..bb700c6e8 --- /dev/null +++ b/doc/changelog.d/465.maintenance.md @@ -0,0 +1 @@ +Feat: Add customization APIs diff --git a/examples/00-basic-pyvista-examples/README.txt b/examples/00-basic-pyvista-examples/README.txt index 568ba6455..54cac51e4 100644 --- a/examples/00-basic-pyvista-examples/README.txt +++ b/examples/00-basic-pyvista-examples/README.txt @@ -1,4 +1,4 @@ Basic usage examples ==================== -These examples show how to use the general plotter included in the Visualization Interface Tool. \ No newline at end of file +These examples show how to use the general plotter included in the Visualization Interface Tool. diff --git a/examples/00-basic-pyvista-examples/animation.py b/examples/00-basic-pyvista-examples/animation.py index 2ce92bace..bdfe21df7 100644 --- a/examples/00-basic-pyvista-examples/animation.py +++ b/examples/00-basic-pyvista-examples/animation.py @@ -36,9 +36,9 @@ from ansys.tools.visualization_interface import Plotter -############################################################################### +############################## # Create sample animation data -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Generate a series of meshes representing a wave propagation over time. def create_wave_mesh(time_step, n_points=50): @@ -62,7 +62,7 @@ def create_wave_mesh(time_step, n_points=50): # Create 30 frames frames = [create_wave_mesh(i) for i in range(30)] -############################################################################### +############################################## # Display animation with interactive controls # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Create and show an animation with play/pause, stop, and frame navigation. @@ -78,7 +78,7 @@ def create_wave_mesh(time_step, n_points=50): # Display with interactive controls animation.show() -############################################################################### +###################### # Interactive Controls # ~~~~~~~~~~~~~~~~~~~~ # The animation window includes the following controls: diff --git a/examples/00-basic-pyvista-examples/customization_api.py b/examples/00-basic-pyvista-examples/customization_api.py new file mode 100644 index 000000000..b550370e5 --- /dev/null +++ b/examples/00-basic-pyvista-examples/customization_api.py @@ -0,0 +1,94 @@ +# 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. + +""" +.. _api_ex: + +Customization API example +========================= + +This example demonstrates how to use the customization API of the visualization interface +to add various elements to a PyVista scene, such as points, lines, planes, and text annotations. +The example also shows how to plot a simple sphere mesh and customize its appearance. + +""" + + +from ansys.tools.visualization_interface import Plotter +import pyvista as pv + +# Create a plotter using the Plotly backend and add basic geometry. + +plotter = Plotter() + +# Add a sphere - this works fine +sphere = pv.Sphere(radius=1.0, center=(0, 0, 0)) +plotter.plot(sphere) + + +# Add point markers to highlight specific locations. + +key_points = [ + [1, 0, 0], # Point on X axis + [0, 1, 0], # Point on Y axis + [0, 0, 1], # Point on Z axis +] + +plotter.add_points(key_points, color='red', size=10) + +# Add line segments to show coordinate axes. + +# X axis +x_axis = [[0, 0, 0], [1.5, 0, 0]] +plotter.add_lines(x_axis, color='red', width=4.0) + +# Y axis +y_axis = [[0, 0, 0], [0, 1.5, 0]] +plotter.add_lines(y_axis, color='green', width=4.0) + +# Z axis +z_axis = [[0, 0, 0], [0, 0, 1.5]] +plotter.add_lines(z_axis, color='blue', width=4.0) + + +# Add a plane to show a reference surface. + +plotter.add_planes( + center=(0, 0, 0), + normal=(0, 0, 1), + i_size=2.5, + j_size=2.5, + color='lightblue', + opacity=0.2 +) + + +# Scene title at the top center +plotter.add_text("Customization API Example", position=(0.5, 0.95), font_size=18, color='white') + +# 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') + +# 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 new file mode 100644 index 000000000..e369e2151 --- /dev/null +++ b/examples/01-basic-plotly-examples/customization_api.py @@ -0,0 +1,100 @@ +# 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. + +""" +.. _plotly_customization_api_example: + +Backend-Agnostic Customization APIs (Plotly) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example demonstrates the backend-agnostic customization APIs with +the Plotly backend. + +The same API calls work with both PyVista and Plotly backends, demonstrating +the true backend-agnostic nature of these methods. +""" + +import pyvista as pv +from ansys.tools.visualization_interface import Plotter +from ansys.tools.visualization_interface.backends.plotly.plotly_interface import PlotlyBackend + +# Create a plotter using the Plotly backend and add basic geometry. + +plotter = Plotter(backend=PlotlyBackend()) + +# Add a sphere - this works fine +sphere = pv.Sphere(radius=1.0, center=(0, 0, 0)) +plotter.plot(sphere) + +# Add point markers to highlight specific locations. + +key_points = [ + [1, 0, 0], # Point on X axis + [0, 1, 0], # Point on Y axis + [0, 0, 1], # Point on Z axis +] + +plotter.add_points(key_points, color='red', size=10) + +# Add line segments to show coordinate axes. + +# X axis +x_axis = [[0, 0, 0], [1.5, 0, 0]] +plotter.add_lines(x_axis, color='red', width=4.0) + +# Y axis +y_axis = [[0, 0, 0], [0, 1.5, 0]] +plotter.add_lines(y_axis, color='green', width=4.0) + +# Z axis +z_axis = [[0, 0, 0], [0, 0, 1.5]] +plotter.add_lines(z_axis, color='blue', width=4.0) + + + +# Add a plane to show a reference surface. + +plotter.add_planes( + center=(0, 0, 0), + normal=(0, 0, 1), + i_size=2.5, + j_size=2.5, + color='lightblue', + opacity=0.2 +) + + +# 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') + +# 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') + + +# Display the visualization with all customizations. + + +# Uncomment to show in browser: +plotter.show() diff --git a/src/ansys/tools/visualization_interface/backends/_base.py b/src/ansys/tools/visualization_interface/backends/_base.py index c7d5f6735..8698877e0 100644 --- a/src/ansys/tools/visualization_interface/backends/_base.py +++ b/src/ansys/tools/visualization_interface/backends/_base.py @@ -22,7 +22,7 @@ """Module for the backend base class.""" from abc import ABC, abstractmethod -from typing import Any, Iterable +from typing import Any, Iterable, List, Optional, Tuple, Union class BaseBackend(ABC): @@ -42,3 +42,131 @@ def plot_iter(self, plotting_list: Iterable): def show(self): """Show the plotted objects.""" raise NotImplementedError("show method must be implemented") + + @abstractmethod + def add_points( + self, + points: Union[List, Any], + color: str = "red", + size: float = 10.0, + **kwargs + ) -> Any: + """Add point markers to the scene. + + Parameters + ---------- + points : Union[List, Any] + Points to add. Can be a list of coordinates or array-like object. + Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + color : str, default: "red" + Color of the points. + size : float, default: 10.0 + Size of the point markers. + **kwargs : dict + Additional backend-specific keyword arguments. + + Returns + ------- + Any + Backend-specific actor or object representing the added points. + """ + raise NotImplementedError("add_points method must be implemented") + + @abstractmethod + def add_lines( + self, + points: Union[List, Any], + connections: Optional[Union[List, Any]] = None, + color: str = "white", + width: float = 1.0, + **kwargs + ) -> Any: + """Add line segments to the scene. + + Parameters + ---------- + points : Union[List, Any] + Points defining the lines. Can be a list of coordinates or array-like object. + Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + connections : Optional[Union[List, Any]], default: None + Line connectivity. If None, connects points sequentially. + Expected format: [[start_idx1, end_idx1], [start_idx2, end_idx2], ...] + or Mx2 array where M is the number of lines. + color : str, default: "white" + Color of the lines. + width : float, default: 1.0 + Width of the lines. + **kwargs : dict + Additional backend-specific keyword arguments. + + Returns + ------- + Any + Backend-specific actor or object representing the added lines. + """ + raise NotImplementedError("add_lines method must be implemented") + + @abstractmethod + def add_planes( + self, + center: Tuple[float, float, float] = (0.0, 0.0, 0.0), + normal: Tuple[float, float, float] = (0.0, 0.0, 1.0), + i_size: float = 1.0, + j_size: float = 1.0, + **kwargs + ) -> Any: + """Add a plane to the scene. + + Parameters + ---------- + center : Tuple[float, float, float], default: (0.0, 0.0, 0.0) + Center point of the plane (x, y, z). + normal : Tuple[float, float, float], default: (0.0, 0.0, 1.0) + Normal vector of the plane (x, y, z). + i_size : float, default: 1.0 + Size of the plane in the i direction. + j_size : float, default: 1.0 + Size of the plane in the j direction. + **kwargs : dict + Additional backend-specific keyword arguments. + + Returns + ------- + Any + Backend-specific actor or object representing the added plane. + """ + raise NotImplementedError("add_planes method must be implemented") + + @abstractmethod + def add_text( + self, + text: str, + position: Union[Tuple[float, float], Tuple[float, float, float], str], + font_size: int = 12, + color: str = "white", + **kwargs + ) -> Any: + """Add text to the scene. + + Parameters + ---------- + text : str + Text string to display. + position : Union[Tuple[float, float], Tuple[float, float, float], str] + Position for the text. Can be 2D (x, y) for screen coordinates, + 3D (x, y, z) for world coordinates, or a 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. + color : str, default: "white" + Color of the text. + **kwargs : dict + Additional backend-specific keyword arguments. + + Returns + ------- + Any + Backend-specific actor or object representing the added text. + """ + raise NotImplementedError("add_text 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 730f65b54..6cd07478f 100644 --- a/src/ansys/tools/visualization_interface/backends/plotly/plotly_interface.py +++ b/src/ansys/tools/visualization_interface/backends/plotly/plotly_interface.py @@ -21,7 +21,7 @@ # SOFTWARE. """Plotly backend interface for visualization.""" -from typing import Any, Iterable, Union +from typing import Any, Iterable, List, Optional, Tuple, Union import plotly.graph_objects as go import pyvista as pv @@ -240,3 +240,257 @@ def show(self, self._fig.write_html(screenshot_str) else: self._fig.write_image(screenshot_str) + + def add_points( + self, + points: Union[List, Any], + color: str = "red", + size: float = 10.0, + **kwargs + ) -> Any: + """Add point markers to the scene. + + Parameters + ---------- + points : Union[List, Any] + Points to add. Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + color : str, default: "red" + Color of the points. + size : float, default: 10.0 + Size of the point markers. + **kwargs : dict + Additional keyword arguments passed to Plotly's Scatter3d. + + Returns + ------- + go.Scatter3d + Plotly Scatter3d trace representing the added points. + """ + import numpy as np + + # Convert points to numpy array + points_array = np.asarray(points) + if points_array.ndim == 1: + points_array = points_array.reshape(-1, 3) + + # Create Plotly scatter trace for points + scatter = go.Scatter3d( + x=points_array[:, 0], + y=points_array[:, 1], + z=points_array[:, 2], + mode='markers', + marker=dict( + size=size, + color=color, + ), + **kwargs + ) + + self._fig.add_trace(scatter) + return scatter + + def add_lines( + self, + points: Union[List, Any], + connections: Optional[Union[List, Any]] = None, + color: str = "white", + width: float = 1.0, + **kwargs + ) -> Any: + """Add line segments to the scene. + + Parameters + ---------- + points : Union[List, Any] + Points defining the lines. Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + connections : Optional[Union[List, Any]], default: None + Line connectivity. If None, connects points sequentially. + Expected format: [[start_idx1, end_idx1], [start_idx2, end_idx2], ...] or Mx2 array. + color : str, default: "white" + Color of the lines. + width : float, default: 1.0 + Width of the lines. + **kwargs : dict + Additional keyword arguments passed to Plotly's Scatter3d. + + Returns + ------- + Union[go.Scatter3d, List[go.Scatter3d]] + Plotly Scatter3d trace(s) representing the added lines. + """ + import numpy as np + + # Convert points to numpy array + points_array = np.asarray(points) + if points_array.ndim == 1: + points_array = points_array.reshape(-1, 3) + + # Create connectivity if not provided (sequential connections) + if connections is None: + n_points = len(points_array) + if n_points < 2: + raise ValueError("At least 2 points are required to create lines") + connections_array = np.array([[i, i + 1] for i in range(n_points - 1)]) + else: + connections_array = np.asarray(connections) + if connections_array.ndim == 1: + connections_array = connections_array.reshape(-1, 2) + + # For Plotly, we need to create separate line traces or use None to break lines + # We'll create line coordinates with None separators for disconnected segments + x_coords = [] + y_coords = [] + z_coords = [] + + for conn in connections_array: + x_coords.extend([points_array[conn[0], 0], points_array[conn[1], 0], None]) + y_coords.extend([points_array[conn[0], 1], points_array[conn[1], 1], None]) + z_coords.extend([points_array[conn[0], 2], points_array[conn[1], 2], None]) + + # Create Plotly scatter trace for lines + line_trace = go.Scatter3d( + x=x_coords, + y=y_coords, + z=z_coords, + mode='lines', + line=dict( + color=color, + width=width, + ), + **kwargs + ) + + self._fig.add_trace(line_trace) + return line_trace + + def add_planes( + self, + center: Tuple[float, float, float] = (0.0, 0.0, 0.0), + normal: Tuple[float, float, float] = (0.0, 0.0, 1.0), + i_size: float = 1.0, + j_size: float = 1.0, + **kwargs + ) -> Any: + """Add a plane to the scene. + + Parameters + ---------- + center : Tuple[float, float, float], default: (0.0, 0.0, 0.0) + Center point of the plane (x, y, z). + normal : Tuple[float, float, float], default: (0.0, 0.0, 1.0) + Normal vector of the plane (x, y, z). + i_size : float, default: 1.0 + Size of the plane in the i direction. + j_size : float, default: 1.0 + Size of the plane in the j direction. + **kwargs : dict + Additional keyword arguments passed to Plotly's Mesh3d (e.g., color, opacity). + + Returns + ------- + go.Mesh3d + Plotly Mesh3d trace representing the added plane. + """ + import numpy as np + + # Normalize the normal vector + normal_array = np.array(normal) + normal_array = normal_array / np.linalg.norm(normal_array) + + # Create two perpendicular vectors to the normal for the plane + # Choose an arbitrary vector not parallel to normal + if abs(normal_array[0]) < 0.9: + v1 = np.cross(normal_array, [1, 0, 0]) + else: + v1 = np.cross(normal_array, [0, 1, 0]) + v1 = v1 / np.linalg.norm(v1) * i_size / 2 + + v2 = np.cross(normal_array, v1) + v2 = v2 / np.linalg.norm(v2) * j_size / 2 + + # Create plane corners + center_array = np.array(center) + corners = [ + center_array - v1 - v2, + center_array + v1 - v2, + center_array + v1 + v2, + center_array - v1 + v2, + ] + + # Extract coordinates + x = [c[0] for c in corners] + y = [c[1] for c in corners] + z = [c[2] for c in corners] + + # Create two triangles to form the plane + # Triangle indices: 0-1-2 and 0-2-3 + i_indices = [0, 0] + j_indices = [1, 2] + k_indices = [2, 3] + + # Set default styling if not provided + if 'color' not in kwargs: + kwargs['color'] = 'lightblue' + if 'opacity' not in kwargs: + kwargs['opacity'] = 0.5 + + # Create Plotly mesh trace for plane + plane_trace = go.Mesh3d( + x=x, + y=y, + z=z, + i=i_indices, + j=j_indices, + k=k_indices, + **kwargs + ) + + self._fig.add_trace(plane_trace) + return plane_trace + + def add_text( + self, + text: str, + position: Union[Tuple[float, float], Tuple[float, float, float], str], + font_size: int = 12, + color: str = "white", + **kwargs + ) -> Any: + """Add text to the scene. + + Parameters + ---------- + text : str + Text string to display. + position : Union[Tuple[float, float], Tuple[float, float, float], str] + Position for the text as 2D screen coordinates (x, y). + Values should be between 0 and 1 for normalized coordinates, + or pixel values for absolute positioning. + font_size : int, default: 12 + Font size for the text. + color : str, default: "white" + Color of the text. + **kwargs : dict + Additional keyword arguments passed to Plotly's annotation. + + Returns + ------- + dict + Plotly annotation representing the added text. + """ + # 2D annotation with normalized coordinates + annotation = dict( + x=position[0] if len(position) > 0 else 0, + y=position[1] if len(position) > 1 else 0, + text=text, + font=dict( + size=font_size, + color=color, + ), + showarrow=False, + xref="paper", + yref="paper", + **kwargs + ) + self._fig.add_annotation(annotation) + return annotation diff --git a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py index becfef4e4..a36ca7405 100644 --- a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py +++ b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista.py @@ -585,6 +585,7 @@ def __init__( use_qt: Optional[bool] = False, show_qt: Optional[bool] = False, custom_picker: AbstractPicker = None, + **plotter_kwargs, ) -> None: """Initialize the generic plotter.""" super().__init__( @@ -594,7 +595,8 @@ def __init__( plot_picked_names, use_qt=use_qt, show_qt=show_qt, - custom_picker=custom_picker + custom_picker=custom_picker, + **plotter_kwargs, ) @property @@ -739,3 +741,214 @@ def create_animation( ) return animation + + def add_points( + self, + points: Union[List, Any], + color: str = "red", + size: float = 10.0, + **kwargs + ) -> "pv.Actor": + """Add point markers to the scene. + + Parameters + ---------- + points : Union[List, Any] + Points to add. Can be a list of coordinates or array-like object. + Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + color : str, default: "red" + Color of the points. + size : float, default: 10.0 + Size of the point markers. + **kwargs : dict + Additional keyword arguments passed to PyVista's add_mesh method. + + Returns + ------- + pv.Actor + PyVista actor representing the added points. + """ + 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 points to the scene + actor = self._pl.scene.add_mesh( + point_cloud, + color=color, + point_size=size, + render_points_as_spheres=True, + **kwargs + ) + + return actor + + def add_lines( + self, + points: Union[List, Any], + connections: Optional[Union[List, Any]] = None, + color: str = "white", + width: float = 1.0, + **kwargs + ) -> "pv.Actor": + """Add line segments to the scene. + + Parameters + ---------- + points : Union[List, Any] + Points defining the lines. Can be a list of coordinates or array-like object. + Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + connections : Optional[Union[List, Any]], default: None + Line connectivity. If None, connects points sequentially. + Expected format: [[start_idx1, end_idx1], [start_idx2, end_idx2], ...] + or Mx2 array where M is the number of lines. + color : str, default: "white" + Color of the lines. + width : float, default: 1.0 + Width of the lines. + **kwargs : dict + Additional keyword arguments passed to PyVista's add_mesh method. + + Returns + ------- + pv.Actor + PyVista actor representing the added lines. + """ + import numpy as np + + # Convert points to numpy array + 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 connectivity if not provided (sequential connections) + if connections is None: + n_points = len(points_array) + if n_points < 2: + raise ValueError("At least 2 points are required to create lines") + # Create sequential line segments + connections_array = np.array([[i, i + 1] for i in range(n_points - 1)]) + else: + connections_array = np.asarray(connections) + + # Ensure connections are 2D + if connections_array.ndim == 1: + connections_array = connections_array.reshape(-1, 2) + + # Create PyVista PolyData with lines + lines = pv.PolyData() + lines.points = points_array + + # Build the lines array for PyVista + # Format: [n_points_in_line, point_idx1, point_idx2, ...] + lines_array = [] + for conn in connections_array: + lines_array.extend([2, conn[0], conn[1]]) + + lines.lines = np.array(lines_array, dtype=np.int64) + + # Add lines to the scene + actor = self._pl.scene.add_mesh( + lines, + color=color, + line_width=width, + **kwargs + ) + + return actor + + def add_planes( + self, + center: tuple[float, float, float] = (0.0, 0.0, 0.0), + normal: tuple[float, float, float] = (0.0, 0.0, 1.0), + i_size: float = 1.0, + j_size: float = 1.0, + **kwargs + ) -> "pv.Actor": + """Add a plane to the scene. + + Parameters + ---------- + center : Tuple[float, float, float], default: (0.0, 0.0, 0.0) + Center point of the plane (x, y, z). + normal : Tuple[float, float, float], default: (0.0, 0.0, 1.0) + Normal vector of the plane (x, y, z). + i_size : float, default: 1.0 + Size of the plane in the i direction. + j_size : float, default: 1.0 + Size of the plane in the j direction. + **kwargs : dict + Additional keyword arguments passed to PyVista's add_mesh method + (e.g., color, opacity). + + Returns + ------- + pv.Actor + PyVista actor representing the added plane. + """ + # Create a PyVista plane + plane = pv.Plane( + center=center, + direction=normal, + i_size=i_size, + j_size=j_size, + ) + + # Add plane to the scene + actor = self._pl.scene.add_mesh(plane, **kwargs) + + return actor + + def add_text( + self, + text: str, + position: Union[tuple[float, float], str], + font_size: int = 12, + color: str = "white", + **kwargs + ) -> "pv.Actor": + """Add text to the scene. + + Parameters + ---------- + text : str + Text string to display. + position : Union[Tuple[float, float], str] + Position for the text. Can be: + + - 2D tuple (x, y) for screen coordinates (pixels from bottom-left) + - String position like 'upper_left', 'upper_right', 'lower_left', + 'lower_right', 'upper_edge', 'lower_edge' (PyVista-specific) + + font_size : int, default: 12 + Font size for the text. + color : str, default: "white" + Color of the text. + **kwargs : dict + Additional keyword arguments passed to PyVista's add_text method. + + Returns + ------- + pv.Actor + PyVista actor representing the added text. + """ + # Handle string positions or 2D coordinates + actor = self._pl.scene.add_text( + text, + position=position, + font_size=font_size, + color=color, + **kwargs + ) + + return actor diff --git a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista_interface.py b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista_interface.py index 1e1b3eb89..e83d80aa3 100644 --- a/src/ansys/tools/visualization_interface/backends/pyvista/pyvista_interface.py +++ b/src/ansys/tools/visualization_interface/backends/pyvista/pyvista_interface.py @@ -456,6 +456,7 @@ def show( # If screenshot is requested, set off_screen to True for the plotter if kwargs.get("screenshot") is not None: self.scene.off_screen = True + if jupyter_backend: # Remove jupyter_backend from show options since we pass it manually kwargs.pop("jupyter_backend", None) diff --git a/src/ansys/tools/visualization_interface/plotter.py b/src/ansys/tools/visualization_interface/plotter.py index f036826a6..14dac59c1 100644 --- a/src/ansys/tools/visualization_interface/plotter.py +++ b/src/ansys/tools/visualization_interface/plotter.py @@ -21,7 +21,7 @@ # SOFTWARE. """Module for the Plotter class.""" -from typing import Any, List, Optional +from typing import Any, List, Optional, Tuple, Union from ansys.tools.visualization_interface.backends._base import BaseBackend from ansys.tools.visualization_interface.backends.pyvista.pyvista import PyVistaBackend @@ -227,3 +227,234 @@ def animate( scalar_bar_args=scalar_bar_args, **plot_kwargs, ) + + def add_points( + self, + points: Union[List, Any], + color: str = "red", + size: float = 10.0, + **kwargs + ) -> Any: + """Add point markers to the scene. + + This method provides a backend-agnostic way to add point markers to the + visualization scene. The points will be rendered using the active backend's + native point rendering capabilities. + + Parameters + ---------- + points : Union[List, Any] + Points to add. Can be a list of coordinates or array-like object. + Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + color : str, default: "red" + Color of the points. Can be a color name (e.g., 'red', 'blue') + or hex color code (e.g., '#FF0000'). + size : float, default: 10.0 + Size of the point markers in pixels or display units + (interpretation depends on backend). + **kwargs : dict + Additional backend-specific keyword arguments for advanced customization. + + Returns + ------- + Any + Backend-specific actor or object representing the added points. + Can be used for further manipulation or removal. + + Examples + -------- + Add simple point markers at three locations: + + >>> from ansys.tools.visualization_interface import Plotter + >>> plotter = Plotter() + >>> points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + >>> plotter.add_points(points, color='blue', size=15) + >>> plotter.show() + + Add points with custom styling: + + >>> import numpy as np + >>> points = np.random.rand(100, 3) + >>> plotter.add_points(points, color='yellow', size=8) + >>> plotter.show() + """ + return self._backend.add_points(points=points, color=color, size=size, **kwargs) + + def add_lines( + self, + points: Union[List, Any], + connections: Optional[Union[List, Any]] = None, + color: str = "white", + width: float = 1.0, + **kwargs + ) -> Any: + """Add line segments to the scene. + + This method provides a backend-agnostic way to add lines to the + visualization scene. Lines can connect points sequentially or based + on explicit connectivity information. + + Parameters + ---------- + points : Union[List, Any] + Points defining the lines. Can be a list of coordinates or array-like object. + Expected format: [[x1, y1, z1], [x2, y2, z2], ...] or Nx3 array. + connections : Optional[Union[List, Any]], default: None + Line connectivity. If None, connects points sequentially (0->1, 1->2, 2->3, ...). + If provided, should define line segments as pairs of point indices: + [[start_idx1, end_idx1], [start_idx2, end_idx2], ...] or Mx2 array + where M is the number of line segments. + color : str, default: "white" + Color of the lines. Can be a color name or hex color code. + width : float, default: 1.0 + Width of the lines in pixels or display units (interpretation depends on backend). + **kwargs : dict + Additional backend-specific keyword arguments for advanced customization. + + Returns + ------- + Any + Backend-specific actor or object representing the added lines. + Can be used for further manipulation or removal. + + Examples + -------- + Add a line connecting points sequentially: + + >>> from ansys.tools.visualization_interface import Plotter + >>> plotter = Plotter() + >>> points = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] + >>> plotter.add_lines(points, color='green', width=2.0) + >>> plotter.show() + + Add specific line segments with explicit connectivity: + + >>> points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]] + >>> connections = [[0, 1], [2, 3], [0, 2]] # Connect specific pairs + >>> plotter.add_lines(points, connections=connections, color='red', width=3.0) + >>> plotter.show() + """ + return self._backend.add_lines( + points=points, connections=connections, color=color, width=width, **kwargs + ) + + def add_planes( + self, + center: Tuple[float, float, float] = (0.0, 0.0, 0.0), + normal: Tuple[float, float, float] = (0.0, 0.0, 1.0), + i_size: float = 1.0, + j_size: float = 1.0, + **kwargs + ) -> Any: + """Add a plane to the scene. + + This method provides a backend-agnostic way to add plane objects to the + visualization scene. Planes are useful for showing reference planes, + symmetry planes, or cutting planes. + + Parameters + ---------- + center : Tuple[float, float, float], default: (0.0, 0.0, 0.0) + Center point of the plane in 3D space (x, y, z). + normal : Tuple[float, float, float], default: (0.0, 0.0, 1.0) + Normal vector of the plane (x, y, z). The vector will be normalized + by the backend if needed. + i_size : float, default: 1.0 + Size of the plane in the i direction (local coordinate system). + j_size : float, default: 1.0 + Size of the plane in the j direction (local coordinate system). + **kwargs : dict + Additional backend-specific keyword arguments for advanced customization + (e.g., color, opacity, resolution). + + Returns + ------- + Any + Backend-specific actor or object representing the added plane. + Can be used for further manipulation or removal. + + Examples + -------- + Add a horizontal plane at z=0: + + >>> from ansys.tools.visualization_interface import Plotter + >>> plotter = Plotter() + >>> plotter.add_planes(center=(0, 0, 0), normal=(0, 0, 1), i_size=2.0, j_size=2.0) + >>> plotter.show() + + Add a vertical plane with custom styling: + + >>> plotter.add_planes( + ... center=(1, 0, 0), + ... normal=(1, 0, 0), + ... i_size=3.0, + ... j_size=3.0, + ... color='lightblue', + ... opacity=0.5 + ... ) + >>> plotter.show() + """ + return self._backend.add_planes( + center=center, normal=normal, i_size=i_size, j_size=j_size, **kwargs + ) + + def add_text( + self, + text: str, + position: Union[Tuple[float, float], str], + font_size: int = 12, + color: str = "white", + **kwargs + ) -> Any: + """Add text to the scene. + + This method provides a backend-agnostic way to add text labels to the + visualization scene. Text is positioned using 2D screen coordinates. + + Parameters + ---------- + text : str + Text string to display. + position : Union[Tuple[float, float], str] + Position for the text. Can be: + + - 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" + Color of the text. Can be a color name or hex color code. + **kwargs : dict + Additional backend-specific keyword arguments for advanced customization + (e.g., font_family, bold, italic, shadow). + + Returns + ------- + Any + Backend-specific actor or object representing the added text. + Can be used for further manipulation or removal. + + Examples + -------- + Add text at a screen position: + + >>> from ansys.tools.visualization_interface import Plotter + >>> plotter = Plotter() + >>> plotter.add_text("Title", position=(10, 10), font_size=18, color='yellow') + >>> plotter.show() + + Add text using a named position: + + >>> plotter.add_text( + ... "Corner Label", + ... position='upper_right', + ... font_size=14, + ... color='red' + ... ) + >>> plotter.show() + """ + return self._backend.add_text( + text=text, position=position, font_size=font_size, color=color, **kwargs + ) diff --git a/tests/test_customization_api.py b/tests/test_customization_api.py new file mode 100644 index 000000000..697356fcb --- /dev/null +++ b/tests/test_customization_api.py @@ -0,0 +1,130 @@ +# 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. +"""Test module for the customization API methods.""" + +from ansys.tools.visualization_interface import Plotter +from ansys.tools.visualization_interface.backends.plotly.plotly_interface import PlotlyBackend + + +def test_add_points_pyvista(): + """Test add_points API with PyVista backend.""" + pl = Plotter() + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + actor = pl.add_points(points, color="red", size=10.0) + assert actor is not None + pl.show() + + +def test_add_points_plotly(): + """Test add_points API with Plotly backend.""" + pl = Plotter(backend=PlotlyBackend()) + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + trace = pl.add_points(points, color="red", size=10.0) + assert trace is not None + + +def test_add_lines_pyvista(): + """Test add_lines API with PyVista backend.""" + pl = Plotter() + points = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] + actor = pl.add_lines(points, color="blue", width=2.0) + assert actor is not None + pl.show() + + +def test_add_lines_plotly(): + """Test add_lines API with Plotly backend.""" + pl = Plotter(backend=PlotlyBackend()) + points = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] + trace = pl.add_lines(points, color="blue", width=2.0) + assert trace is not None + + +def test_add_lines_with_connections_pyvista(): + """Test add_lines API with explicit connections using PyVista backend.""" + pl = Plotter() + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]] + connections = [[0, 1], [2, 3]] + actor = pl.add_lines(points, connections=connections, color="green", width=2.0) + assert actor is not None + pl.show() + + +def test_add_lines_with_connections_plotly(): + """Test add_lines API with explicit connections using Plotly backend.""" + pl = Plotter(backend=PlotlyBackend()) + points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]] + connections = [[0, 1], [2, 3]] + trace = pl.add_lines(points, connections=connections, color="green", width=2.0) + assert trace is not None + + +def test_add_planes_pyvista(): + """Test add_planes API with PyVista backend.""" + pl = Plotter() + actor = pl.add_planes( + center=(0, 0, 0), + normal=(0, 0, 1), + i_size=2.0, + j_size=2.0, + color="lightblue", + opacity=0.5 + ) + assert actor is not None + pl.show() + + +def test_add_planes_plotly(): + """Test add_planes API with Plotly backend.""" + pl = Plotter(backend=PlotlyBackend()) + trace = pl.add_planes( + center=(0, 0, 0), + normal=(0, 0, 1), + i_size=2.0, + j_size=2.0, + color="lightblue", + opacity=0.5 + ) + assert trace is not None + + +def test_add_text_pyvista(): + """Test add_text API with PyVista backend using 2D screen coordinates.""" + pl = Plotter() + actor = pl.add_text("Test Label", position=(10, 10), font_size=14, color="yellow") + assert actor is not None + pl.show() + + +def test_add_text_pyvista_string_position(): + """Test add_text API with PyVista backend using string position.""" + pl = Plotter() + actor = pl.add_text("Test Label", position='upper_left', font_size=14, color="yellow") + assert actor is not None + pl.show() + + +def test_add_text_plotly(): + """Test add_text API with Plotly backend using 2D normalized coordinates.""" + 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