From d6b71916b1f92828eb1ec5813658a2becf8e8e07 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Tue, 29 Jul 2025 17:47:26 +0200 Subject: [PATCH 1/6] FEAT: Added most matplotlib methods implemented with plotly --- .../aedt/core/visualization/plot/plotly.py | 762 ++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 src/ansys/aedt/core/visualization/plot/plotly.py diff --git a/src/ansys/aedt/core/visualization/plot/plotly.py b/src/ansys/aedt/core/visualization/plot/plotly.py new file mode 100644 index 00000000000..e2a7443c3d8 --- /dev/null +++ b/src/ansys/aedt/core/visualization/plot/plotly.py @@ -0,0 +1,762 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 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. + +from dataclasses import dataclass +from dataclasses import field +from enum import Enum +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union +import warnings + +import numpy as np + +from ansys.aedt.core.generic.general_methods import ( + pyaedt_function_handler, +) +from ansys.aedt.core.internal.checks import ERROR_GRAPHICS_REQUIRED +from ansys.aedt.core.internal.checks import check_graphics_available + +# Check that graphics are available +try: + check_graphics_available() + import plotly.graph_objects as go + import plotly.offline as pyo + +except ImportError: + warnings.warn(ERROR_GRAPHICS_REQUIRED) + + +class LineStyle(Enum): + """Line style options for Plotly traces.""" + + SOLID = "solid" + DOT = "dot" + DASH = "dash" + LONGDASH = "longdash" + DASHDOT = "dashdot" + LONGDASHDOT = "longdashdot" + + +class ScaleType(Enum): + """Axis scale types for Plotly plots.""" + + LINEAR = "linear" + LOG = "log" + + +class DataType(Enum): + """Data type for trace input.""" + + CARTESIAN = 0 + SPHERICAL = 1 + + +class MarkerSymbol(Enum): + """Marker symbol options for Plotly traces.""" + + CIRCLE = "circle" + SQUARE = "square" + DIAMOND = "diamond" + CROSS = "cross" + X = "x" + TRIANGLE_UP = "triangle-up" + TRIANGLE_DOWN = "triangle-down" + + +@dataclass +class TraceProperties: + """Properties for customizing a Plotly trace.""" + + x_label: str = "" + y_label: str = "" + z_label: str = "" + line_style: Union[LineStyle, str] = LineStyle.SOLID + line_width: float = 2.0 + line_color: Optional[str] = None + marker_symbol: Union[MarkerSymbol, str] = MarkerSymbol.CIRCLE + marker_size: float = 8.0 + marker_color: Optional[str] = None + fill_symbol: bool = False + + def __post_init__(self): + """Convert string values to enums if needed.""" + if isinstance(self.line_style, str): + try: + self.line_style = LineStyle(self.line_style) + except ValueError: + # Keep as string if not a valid enum value + pass + + if isinstance(self.marker_symbol, str): + try: + self.marker_symbol = MarkerSymbol(self.marker_symbol) + except ValueError: + # Keep as string if not a valid enum value + pass + + +@dataclass +class PlotlyTrace: + """A trace for Plotly plots.""" + + name: str = "" + properties: TraceProperties = field(default_factory=TraceProperties) + _cartesian_data: Optional[List[np.ndarray]] = field(default=None, init=False) + _spherical_data: Optional[List[np.ndarray]] = field(default=None, init=False) + + # Convenience properties for backward compatibility and easy access + @property + def x_label(self) -> str: + """X-axis label.""" + return self.properties.x_label + + @x_label.setter + def x_label(self, value: str) -> None: + self.properties.x_label = value + + @property + def y_label(self) -> str: + """Y-axis label.""" + return self.properties.y_label + + @y_label.setter + def y_label(self, value: str) -> None: + self.properties.y_label = value + + @property + def z_label(self) -> str: + """Z-axis label.""" + return self.properties.z_label + + @z_label.setter + def z_label(self, value: str) -> None: + self.properties.z_label = value + + @property + def line_style(self) -> Union[LineStyle, str]: + """Line style.""" + return self.properties.line_style + + @line_style.setter + def line_style(self, value: Union[LineStyle, str]) -> None: + self.properties.line_style = value + + @property + def line_width(self) -> float: + """Line width.""" + return self.properties.line_width + + @line_width.setter + def line_width(self, value: float) -> None: + self.properties.line_width = value + + @property + def line_color(self) -> Optional[str]: + """Line color.""" + return self.properties.line_color + + @line_color.setter + def line_color(self, value: Optional[str]) -> None: + self.properties.line_color = value + + @property + def marker_symbol(self) -> Union[MarkerSymbol, str]: + """Marker symbol.""" + return self.properties.marker_symbol + + @marker_symbol.setter + def marker_symbol(self, value: Union[MarkerSymbol, str]) -> None: + self.properties.marker_symbol = value + + @property + def marker_size(self) -> float: + """Marker size.""" + return self.properties.marker_size + + @marker_size.setter + def marker_size(self, value: float) -> None: + self.properties.marker_size = value + + @property + def marker_color(self) -> Optional[str]: + """Marker color.""" + return self.properties.marker_color + + @marker_color.setter + def marker_color(self, value: Optional[str]) -> None: + self.properties.marker_color = value + + @property + def fill_symbol(self) -> bool: + """Fill marker flag.""" + return self.properties.fill_symbol + + @fill_symbol.setter + def fill_symbol(self, value: bool) -> None: + self.properties.fill_symbol = value + + @property + def cartesian_data(self) -> Optional[List[np.ndarray]]: + """Cartesian data as [x, y, z].""" + return self._cartesian_data + + @cartesian_data.setter + def cartesian_data(self, val: List[Any]) -> None: + if val is None: + self._cartesian_data = None + return + + self._cartesian_data = [] + for el in val: + if isinstance(el, (float, int, str)): + self._cartesian_data.append(el) + else: + self._cartesian_data.append(np.array(el, dtype=float)) + + # Automatically add z=0 for 2D data + if len(self._cartesian_data) == 2: + self._cartesian_data.append( + np.zeros(len(self._cartesian_data[-1])) + ) + + @property + def spherical_data(self) -> Optional[List[np.ndarray]]: + """Spherical data as [r, theta, phi] (angles in degrees).""" + return self._spherical_data + + @spherical_data.setter + def spherical_data(self, val: List[Any]) -> None: + if val is None: + self._spherical_data = None + return + + self._spherical_data = [] + for el in val: + if isinstance(el, (float, int, str)): + self._spherical_data.append(el) + else: + self._spherical_data.append(np.array(el, dtype=float)) + + +def is_notebook(): + """Return True if running in a Jupyter notebook.""" + try: + shell = get_ipython().__class__.__name__ + return shell in ["ZMQInteractiveShell"] + except NameError: + return False + + +class PlotlyPlotter: + """Manager for creating and displaying Plotly plots.""" + + def __init__( + self, + title: str = "", + show_legend: bool = True, + x_scale: Union[ScaleType, str] = ScaleType.LINEAR, + y_scale: Union[ScaleType, str] = ScaleType.LINEAR, + size: Tuple[int, int] = (800, 600), + grid_color: str = "lightgray", + background_color: str = "white", + plot_color: str = "white" + ): + """Initialize the PlotlyPlotter.""" + self._traces: Dict[str, PlotlyTrace] = {} + self._limit_lines: Dict[str, Any] = {} + self._notes: List[str] = [] + self.title = title + self.show_legend = show_legend + self.size = size + self.fig: Optional[Any] = None + self.grid_color = grid_color + self.grid_enable_major_x = True + self.grid_enable_major_y = True + self.grid_enable_minor_x = False + self.grid_enable_minor_y = False + self.background_color = background_color + self.plot_color = plot_color + self._x_scale = self._validate_scale(x_scale) + self._y_scale = self._validate_scale(y_scale) + + @staticmethod + def _validate_scale(scale: Union[ScaleType, str]) -> ScaleType: + """Convert scale to ScaleType enum.""" + if isinstance(scale, ScaleType): + return scale + if isinstance(scale, str): + try: + return ScaleType(scale) + except ValueError: + raise ValueError(f"Invalid scale '{scale}'. Valid options: {[s.value for s in ScaleType]}") + raise TypeError(f"Scale must be ScaleType or str, got {type(scale)}") + + @property + def traces(self) -> Dict[str, PlotlyTrace]: + """All traces in the plot.""" + return self._traces + + @property + def trace_names(self) -> List[str]: + """Names of all traces.""" + return list(self._traces.keys()) + + @property + def x_scale(self) -> ScaleType: + """X axis scale type.""" + return self._x_scale + + @x_scale.setter + def x_scale(self, value: Union[ScaleType, str]) -> None: + self._x_scale = self._validate_scale(value) + + @property + def y_scale(self) -> ScaleType: + """Y axis scale type.""" + return self._y_scale + + @y_scale.setter + def y_scale(self, value: Union[ScaleType, str]) -> None: + self._y_scale = self._validate_scale(value) + + @pyaedt_function_handler() + def add_trace( + self, + plot_data: List[Any], + data_type: Union[DataType, int] = DataType.CARTESIAN, + properties: Optional[Union[TraceProperties, Dict[str, Any]]] = None, + name: str = "", + x_label: str = "", + y_label: str = "", + z_label: str = "", + line_style: Union[LineStyle, str] = LineStyle.SOLID, + line_width: float = 2.0, + line_color: Optional[str] = None, + marker_symbol: Union[MarkerSymbol, str] = MarkerSymbol.CIRCLE, + marker_size: float = 8.0, + marker_color: Optional[str] = None, + fill_symbol: bool = False + ) -> bool: + """Add a trace to the plot. + + Accepts a TraceProperties object, a dictionary, or individual parameters. + """ + # Convert data_type to enum if needed + if isinstance(data_type, int): + data_type = DataType(data_type) + + # Generate trace name if not provided + trace_name = name or f"Trace_{len(self.traces)}" + + # Handle properties in a Pythonic way + if properties is None: + # Use individual parameters + trace_props = TraceProperties( + x_label=x_label, + y_label=y_label, + z_label=z_label, + line_style=line_style, + line_width=line_width, + line_color=line_color, + marker_symbol=marker_symbol, + marker_size=marker_size, + marker_color=marker_color, + fill_symbol=fill_symbol + ) + elif isinstance(properties, TraceProperties): + # Use provided TraceProperties dataclass + trace_props = properties + elif isinstance(properties, dict): + # Convert dictionary to TraceProperties for backward compatibility + trace_props = TraceProperties( + x_label=properties.get("x_label", x_label), + y_label=properties.get("y_label", y_label), + z_label=properties.get("z_label", z_label), + line_style=properties.get("line_style", line_style), + line_width=properties.get("line_width", line_width), + line_color=properties.get("line_color", line_color), + marker_symbol=properties.get("marker_symbol", marker_symbol), + marker_size=properties.get("marker_size", marker_size), + marker_color=properties.get("marker_color", marker_color), + fill_symbol=properties.get("fill_symbol", fill_symbol) + ) + else: + raise TypeError( + f"Properties must be TraceProperties, dict, or None. " + f"Got {type(properties)}" + ) + + # Create trace with the properties + trace = PlotlyTrace(name=trace_name, properties=trace_props) + + # Set data based on type + if data_type == DataType.CARTESIAN: + trace.cartesian_data = plot_data + else: + trace.spherical_data = plot_data + + self._traces[trace.name] = trace + return True + + @pyaedt_function_handler() + def _retrieve_traces(self, traces): + """Return traces by name, index, or list.""" + if traces is None: + return list(self._traces.values()) + + if isinstance(traces, str): + if traces in self._traces: + return [self._traces[traces]] + else: + return [] + + if isinstance(traces, int): + trace_list = list(self._traces.values()) + if 0 <= traces < len(trace_list): + return [trace_list[traces]] + else: + return [] + + if isinstance(traces, list): + result = [] + for trace in traces: + retrieved = self._retrieve_traces(trace) + result.extend(retrieved) + return result + + return [] + + @pyaedt_function_handler() + def plot_2d(self, traces=None, snapshot_path=None, show=True): + """Create a 2D Plotly plot for the given traces.""" + traces_to_plot = self._retrieve_traces(traces) + if not traces_to_plot: + return None + + self.fig = go.Figure() + + for trace in traces_to_plot: + if trace.cartesian_data and len(trace.cartesian_data) >= 2: + x_data = trace.cartesian_data[0] + y_data = trace.cartesian_data[1] + + # Handle enum values properly + line_style = ( + trace.line_style.value + if isinstance(trace.line_style, LineStyle) + else trace.line_style + ) + marker_symbol = ( + trace.marker_symbol.value + if isinstance(trace.marker_symbol, MarkerSymbol) + else trace.marker_symbol + ) + + self.fig.add_trace(go.Scatter( + x=x_data, + y=y_data, + mode='lines+markers' if trace.fill_symbol else 'lines', + name=trace.name, + line=dict( + color=trace.line_color, + width=trace.line_width, + dash=line_style + ), + marker=dict( + symbol=marker_symbol, + size=trace.marker_size, + color=trace.marker_color + ) if trace.fill_symbol else None + )) + + # Update layout + self.fig.update_layout( + title=self.title, + xaxis=dict( + title=traces_to_plot[0].x_label if traces_to_plot else "", + type=self.x_scale.value, + showgrid=self.grid_enable_major_x, + gridcolor=self.grid_color + ), + yaxis=dict( + title=traces_to_plot[0].y_label if traces_to_plot else "", + type=self.y_scale.value, + showgrid=self.grid_enable_major_y, + gridcolor=self.grid_color + ), + showlegend=self.show_legend, + plot_bgcolor=self.plot_color, + paper_bgcolor=self.background_color, + width=self.size[0], + height=self.size[1] + ) + + if snapshot_path: + self.fig.write_image(snapshot_path) + + if show: + self._show_plot(self.fig) + + return self.fig + + @pyaedt_function_handler() + def plot_polar(self, traces=None, snapshot_path=None, show=True): + """Create a polar Plotly plot for the given traces.""" + traces_to_plot = self._retrieve_traces(traces) + if not traces_to_plot: + return None + + self.fig = go.Figure() + + for trace in traces_to_plot: + if trace.cartesian_data and len(trace.cartesian_data) >= 2: + theta = trace.cartesian_data[0] # Assuming theta in degrees + r = trace.cartesian_data[1] + + # Handle enum values properly + line_style = ( + trace.line_style.value + if isinstance(trace.line_style, LineStyle) + else trace.line_style + ) + marker_symbol = ( + trace.marker_symbol.value + if isinstance(trace.marker_symbol, MarkerSymbol) + else trace.marker_symbol + ) + + self.fig.add_trace(go.Scatterpolar( + r=r, + theta=theta, + mode='lines+markers' if trace.fill_symbol else 'lines', + name=trace.name, + line=dict( + color=trace.line_color, + width=trace.line_width, + dash=line_style + ), + marker=dict( + symbol=marker_symbol, + size=trace.marker_size, + color=trace.marker_color + ) if trace.fill_symbol else None + )) + + # Update layout for polar plot + self.fig.update_layout( + title=self.title, + polar=dict( + radialaxis=dict( + visible=True, + title=traces_to_plot[0].y_label if traces_to_plot else "", + showgrid=self.grid_enable_major_y, + gridcolor=self.grid_color + ), + angularaxis=dict( + direction="counterclockwise", + period=360 + ) + ), + showlegend=self.show_legend, + plot_bgcolor=self.plot_color, + paper_bgcolor=self.background_color, + width=self.size[0], + height=self.size[1] + ) + + if snapshot_path: + self.fig.write_image(snapshot_path) + + if show: + self._show_plot(self.fig) + + return self.fig + + @pyaedt_function_handler() + def plot_3d(self, trace=0, snapshot_path=None, show=True): + """Create a 3D Plotly plot for the given trace.""" + traces_to_plot = self._retrieve_traces(trace) + if not traces_to_plot: + return None + + trace_obj = traces_to_plot[0] + + if not trace_obj.cartesian_data or len(trace_obj.cartesian_data) < 3: + return None + + x_data = trace_obj.cartesian_data[0] + y_data = trace_obj.cartesian_data[1] + z_data = trace_obj.cartesian_data[2] + + self.fig = go.Figure() + + # Handle enum values properly + marker_symbol = ( + trace_obj.marker_symbol.value + if isinstance(trace_obj.marker_symbol, MarkerSymbol) + else trace_obj.marker_symbol + ) + + self.fig.add_trace(go.Scatter3d( + x=x_data, + y=y_data, + z=z_data, + mode='lines+markers' if trace_obj.fill_symbol else 'lines', + name=trace_obj.name, + line=dict( + color=trace_obj.line_color, + width=trace_obj.line_width + ), + marker=dict( + symbol=marker_symbol, + size=trace_obj.marker_size, + color=trace_obj.marker_color + ) if trace_obj.fill_symbol else None + )) + + # Update layout for 3D plot + self.fig.update_layout( + title=self.title, + scene=dict( + xaxis_title=trace_obj.x_label, + yaxis_title=trace_obj.y_label, + zaxis_title=trace_obj.z_label, + bgcolor=self.plot_color + ), + showlegend=self.show_legend, + paper_bgcolor=self.background_color, + width=self.size[0], + height=self.size[1] + ) + + if snapshot_path: + self.fig.write_image(snapshot_path) + + if show: + self._show_plot(self.fig) + + return self.fig + + @pyaedt_function_handler() + def plot_contour(self, trace=0, levels=10, snapshot_path=None, show=True): + """Create a contour Plotly plot for the given trace.""" + traces_to_plot = self._retrieve_traces(trace) + if not traces_to_plot: + return None + + trace_obj = traces_to_plot[0] + + if not trace_obj.cartesian_data or len(trace_obj.cartesian_data) < 3: + return None + + x_data = trace_obj.cartesian_data[0] + y_data = trace_obj.cartesian_data[1] + z_data = trace_obj.cartesian_data[2] + + self.fig = go.Figure() + + self.fig.add_trace(go.Contour( + x=x_data, + y=y_data, + z=z_data, + name=trace_obj.name, + ncontours=levels, + colorscale='Viridis' + )) + + # Update layout + self.fig.update_layout( + title=self.title, + xaxis_title=trace_obj.x_label, + yaxis_title=trace_obj.y_label, + showlegend=self.show_legend, + plot_bgcolor=self.plot_color, + paper_bgcolor=self.background_color, + width=self.size[0], + height=self.size[1] + ) + + if snapshot_path: + self.fig.write_image(snapshot_path) + + if show: + self._show_plot(self.fig) + + return self.fig + + @pyaedt_function_handler() + def _show_plot(self, fig): + """Display the plot in a notebook or browser.""" + if is_notebook(): + fig.show() + else: + from http.server import HTTPServer, SimpleHTTPRequestHandler + import socket, threading, time, webbrowser + + # Generate HTML content in memory + html_content = pyo.plot( + fig, output_type='div', include_plotlyjs=True + ) + + # Create a simple HTTP server to serve the content + class PlotlyHandler(SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == '/' or self.path == '/plot.html': + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html_content.encode('utf-8')) + else: + self.send_error(404) + + # Start server on available port + sock = socket.socket() + sock.bind(('', 0)) + port = sock.getsockname()[1] + sock.close() + + server = HTTPServer(('localhost', port), PlotlyHandler) + + # Start server in background thread + server_thread = threading.Thread( + target=server.serve_forever + ) + server_thread.daemon = True + server_thread.start() + + # Open browser + webbrowser.open(f'http://localhost:{port}/plot.html') + + # Keep server running for a reasonable time then shut down + def shutdown_server(): + time.sleep(30) # Keep server alive for 30 seconds + server.shutdown() + + shutdown_thread = threading.Thread(target=shutdown_server) + shutdown_thread.daemon = True + shutdown_thread.start() From 5d6e293a4a7c0302a925552fbb328b3c1f4a172c Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 30 Jul 2025 15:20:06 +0200 Subject: [PATCH 2/6] FEAT: Add limit line and region limit properties to Plotly plots --- .../aedt/core/visualization/plot/plotly.py | 528 ++++++++++++++++-- 1 file changed, 492 insertions(+), 36 deletions(-) diff --git a/src/ansys/aedt/core/visualization/plot/plotly.py b/src/ansys/aedt/core/visualization/plot/plotly.py index e2a7443c3d8..41267770805 100644 --- a/src/ansys/aedt/core/visualization/plot/plotly.py +++ b/src/ansys/aedt/core/visualization/plot/plotly.py @@ -272,6 +272,50 @@ def is_notebook(): return False +class LimitLineProperties: + """Properties for customizing a Plotly limit line.""" + def __init__(self, x=None, y=None, orientation="h", color="red", width=2, dash="dash", name=""): + self.x = x + self.y = y + self.orientation = orientation # 'h' for horizontal, 'v' for vertical + self.color = color + self.width = width + self.dash = dash + self.name = name + + +class PlotlyLimitLine: + """A limit line for Plotly plots.""" + def __init__(self, properties: LimitLineProperties): + self.properties = properties + self.name = properties.name or f"LimitLine_{id(self)}" + + +class RegionLimitProperties: + """Properties for customizing a 3D region limit.""" + def __init__(self, x_min=None, x_max=None, y_min=None, y_max=None, z_min=None, z_max=None, + color="lightblue", opacity=0.3, name="", show_edges=True, edge_color="blue", edge_width=2): + self.x_min = x_min + self.x_max = x_max + self.y_min = y_min + self.y_max = y_max + self.z_min = z_min + self.z_max = z_max + self.color = color + self.opacity = opacity + self.name = name + self.show_edges = show_edges + self.edge_color = edge_color + self.edge_width = edge_width + + +class PlotlyRegionLimit: + """A region limit for 3D Plotly plots.""" + def __init__(self, properties: RegionLimitProperties): + self.properties = properties + self.name = properties.name or f"RegionLimit_{id(self)}" + + class PlotlyPlotter: """Manager for creating and displaying Plotly plots.""" @@ -289,6 +333,7 @@ def __init__( """Initialize the PlotlyPlotter.""" self._traces: Dict[str, PlotlyTrace] = {} self._limit_lines: Dict[str, Any] = {} + self._region_limits: Dict[str, Any] = {} self._notes: List[str] = [] self.title = title self.show_legend = show_legend @@ -451,6 +496,412 @@ def _retrieve_traces(self, traces): return [] + @pyaedt_function_handler() + def add_limit( + self, + x=None, + y=None, + orientation="h", + color="red", + width=2, + dash="dash", + name="", + properties=None + ): + """Add a limit line or plane to the plot.""" + if properties is None: + props = LimitLineProperties(x=x, y=y, orientation=orientation, color=color, width=width, dash=dash, name=name) + else: + props = properties + limit_line = PlotlyLimitLine(props) + self._limit_lines[limit_line.name] = limit_line + return True + + @pyaedt_function_handler() + def add_region_limit( + self, + x_min=None, x_max=None, y_min=None, y_max=None, z_min=None, z_max=None, + color="lightblue", opacity=0.3, name="", show_edges=True, edge_color="blue", edge_width=2, + properties=None + ): + """Add a 3D region limit (bounding box) to the plot. + + Parameters + ---------- + x_min, x_max : float, optional + X-axis bounds for the region + y_min, y_max : float, optional + Y-axis bounds for the region + z_min, z_max : float, optional + Z-axis bounds for the region + color : str, optional + Fill color for the region box + opacity : float, optional + Opacity of the region box (0-1) + name : str, optional + Name for the region limit + show_edges : bool, optional + Whether to show edge lines + edge_color : str, optional + Color of the edge lines + edge_width : float, optional + Width of the edge lines + properties : RegionLimitProperties, optional + Properties object for the region limit + """ + if properties is None: + props = RegionLimitProperties( + x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, z_min=z_min, z_max=z_max, + color=color, opacity=opacity, name=name, show_edges=show_edges, + edge_color=edge_color, edge_width=edge_width + ) + else: + props = properties + region_limit = PlotlyRegionLimit(props) + self._region_limits[region_limit.name] = region_limit + return True + + @property + def limit_lines(self): + """All limit lines in the plot.""" + return self._limit_lines + + @property + def limit_line_names(self): + """Names of all limit lines.""" + return list(self._limit_lines.keys()) + + @property + def region_limits(self): + """All region limits in the plot.""" + return self._region_limits + + @property + def region_limit_names(self): + """Names of all region limits.""" + return list(self._region_limits.keys()) + + def _add_limit_to_fig(self, fig, plot_type="2d"): + """Internal: Add limit lines to the figure.""" + for ll in self._limit_lines.values(): + props = ll.properties + if plot_type == "2d": + if props.orientation == "h" and props.y is not None: + # Add infinite horizontal line + fig.add_hline( + y=props.y, + line_color=props.color, + line_width=props.width, + line_dash=props.dash, + annotation_text=props.name if props.name else None, + annotation_position="top right" if props.name else None + ) + elif props.orientation == "v" and props.x is not None: + # Add infinite vertical line + fig.add_vline( + x=props.x, + line_color=props.color, + line_width=props.width, + line_dash=props.dash, + annotation_text=props.name if props.name else None, + annotation_position="top right" if props.name else None + ) + elif plot_type == "polar": + # Add limit lines as Scatterpolar traces + if props.orientation == "h" and props.y is not None: + # Constant radius (r) + theta = np.linspace(0, 360, 361) + r = np.full_like(theta, props.y) + fig.add_trace(go.Scatterpolar( + r=r, + theta=theta, + mode='lines', + name=props.name or f"r={props.y}", + line=dict(color=props.color, width=props.width, dash=props.dash), + showlegend=True + )) + elif props.orientation == "v" and props.x is not None: + # Constant angle (theta) - extend radius from 0 to maximum visible radius + # Get the maximum radius from existing traces or use a reasonable default + max_radius = 10 # Default fallback + try: + if hasattr(fig, 'data') and fig.data: + for trace in fig.data: + if hasattr(trace, 'r') and trace.r is not None: + max_radius = max(max_radius, np.max(trace.r) * 1.2) + except: + pass + + r = np.linspace(0, max_radius, 100) + theta = np.full_like(r, props.x) + fig.add_trace(go.Scatterpolar( + r=r, + theta=theta, + mode='lines', + name=props.name or f"theta={props.x}", + line=dict(color=props.color, width=props.width, dash=props.dash), + showlegend=True + )) + elif plot_type == "3d": + # For 3D plots, create infinite planes and lines + if props.orientation == "h" and props.y is not None: + # Horizontal plane at y = constant (infinite in x and z) + # Get the plot bounds to create a large enough plane + x_range = [-10, 10] # Default range + z_range = [-10, 10] + + # Try to get actual data bounds for better scaling + try: + if hasattr(fig, 'data') and fig.data: + x_values = [] + z_values = [] + for trace in fig.data: + if hasattr(trace, 'x') and trace.x is not None: + x_values.extend(trace.x) + if hasattr(trace, 'z') and trace.z is not None: + z_values.extend(trace.z) + + if x_values: + x_min, x_max = min(x_values), max(x_values) + x_range = [x_min - abs(x_max - x_min), x_max + abs(x_max - x_min)] + if z_values: + z_min, z_max = min(z_values), max(z_values) + z_range = [z_min - abs(z_max - z_min), z_max + abs(z_max - z_min)] + except: + pass + + # Create a grid for the plane + x_plane = [x_range[0], x_range[1], x_range[1], x_range[0]] + y_plane = [props.y, props.y, props.y, props.y] + z_plane = [z_range[0], z_range[0], z_range[1], z_range[1]] + + # Define triangular faces for the plane + i = [0, 0] # vertex indices + j = [1, 2] + k = [2, 3] + + fig.add_trace(go.Mesh3d( + x=x_plane, + y=y_plane, + z=z_plane, + i=i, j=j, k=k, + color=props.color, + opacity=0.4, + name=props.name or f"Y={props.y}", + showlegend=True, + flatshading=True, + lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), + lightposition=dict(x=100, y=200, z=0) + )) + + elif props.orientation == "v" and props.x is not None: + # Vertical plane at x = constant (infinite in y and z) + y_range = [-10, 10] + z_range = [-10, 10] + + # Try to get actual data bounds + try: + if hasattr(fig, 'data') and fig.data: + y_values = [] + z_values = [] + for trace in fig.data: + if hasattr(trace, 'y') and trace.y is not None: + y_values.extend(trace.y) + if hasattr(trace, 'z') and trace.z is not None: + z_values.extend(trace.z) + + if y_values: + y_min, y_max = min(y_values), max(y_values) + y_range = [y_min - abs(y_max - y_min), y_max + abs(y_max - y_min)] + if z_values: + z_min, z_max = min(z_values), max(z_values) + z_range = [z_min - abs(z_max - z_min), z_max + abs(z_max - z_min)] + except: + pass + + # Create a grid for the plane + x_plane = [props.x, props.x, props.x, props.x] + y_plane = [y_range[0], y_range[1], y_range[1], y_range[0]] + z_plane = [z_range[0], z_range[0], z_range[1], z_range[1]] + + # Define triangular faces for the plane + i = [0, 0] + j = [1, 2] + k = [2, 3] + + fig.add_trace(go.Mesh3d( + x=x_plane, + y=y_plane, + z=z_plane, + i=i, j=j, k=k, + color=props.color, + opacity=0.4, + name=props.name or f"X={props.x}", + showlegend=True, + flatshading=True, + lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), + lightposition=dict(x=100, y=200, z=0) + )) + + # Add support for Z-plane limits + elif hasattr(props, 'z') and props.z is not None: + # Horizontal plane at z = constant (infinite in x and y) + x_range = [-10, 10] + y_range = [-10, 10] + + # Try to get actual data bounds + try: + if hasattr(fig, 'data') and fig.data: + x_values = [] + y_values = [] + for trace in fig.data: + if hasattr(trace, 'x') and trace.x is not None: + x_values.extend(trace.x) + if hasattr(trace, 'y') and trace.y is not None: + y_values.extend(trace.y) + + if x_values: + x_min, x_max = min(x_values), max(x_values) + x_range = [x_min - abs(x_max - x_min), x_max + abs(x_max - x_min)] + if y_values: + y_min, y_max = min(y_values), max(y_values) + y_range = [y_min - abs(y_max - y_min), y_max + abs(y_max - y_min)] + except: + pass + + # Create a grid for the plane + x_plane = [x_range[0], x_range[1], x_range[1], x_range[0]] + y_plane = [y_range[0], y_range[0], y_range[1], y_range[1]] + z_plane = [props.z, props.z, props.z, props.z] + + # Define triangular faces for the plane + i = [0, 0] + j = [1, 2] + k = [2, 3] + + fig.add_trace(go.Mesh3d( + x=x_plane, + y=y_plane, + z=z_plane, + i=i, j=j, k=k, + color=props.color, + opacity=0.4, + name=props.name or f"Z={props.z}", + showlegend=True, + flatshading=True, + lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), + lightposition=dict(x=100, y=200, z=0) + )) + elif plot_type == "contour": + # For contour plots, we can overlay lines on the 2D contour + if props.orientation == "h" and props.y is not None: + fig.add_hline( + y=props.y, + line_color=props.color, + line_width=props.width, + line_dash=props.dash, + annotation_text=props.name if props.name else None + ) + elif props.orientation == "v" and props.x is not None: + fig.add_vline( + x=props.x, + line_color=props.color, + line_width=props.width, + line_dash=props.dash, + annotation_text=props.name if props.name else None + ) + + def _add_region_limits_to_fig(self, fig, plot_type="3d"): + """Internal: Add region limits to the figure (3D only).""" + if plot_type != "3d": + return + + for rl in self._region_limits.values(): + props = rl.properties + + # Create 3D bounding box + if all(v is not None for v in [props.x_min, props.x_max, props.y_min, props.y_max, props.z_min, props.z_max]): + # Define the 8 vertices of the box + vertices = [ + [props.x_min, props.y_min, props.z_min], # 0 + [props.x_max, props.y_min, props.z_min], # 1 + [props.x_max, props.y_max, props.z_min], # 2 + [props.x_min, props.y_max, props.z_min], # 3 + [props.x_min, props.y_min, props.z_max], # 4 + [props.x_max, props.y_min, props.z_max], # 5 + [props.x_max, props.y_max, props.z_max], # 6 + [props.x_min, props.y_max, props.z_max], # 7 + ] + + # Define the 12 triangular faces (2 triangles per face, 6 faces) + faces = [ + # Bottom face (z_min) + [0, 1, 2], [0, 2, 3], + # Top face (z_max) + [4, 6, 5], [4, 7, 6], + # Front face (y_min) + [0, 4, 5], [0, 5, 1], + # Back face (y_max) + [2, 6, 7], [2, 7, 3], + # Left face (x_min) + [0, 3, 7], [0, 7, 4], + # Right face (x_max) + [1, 5, 6], [1, 6, 2], + ] + + # Extract coordinates + x_coords = [v[0] for v in vertices] + y_coords = [v[1] for v in vertices] + z_coords = [v[2] for v in vertices] + + # Create mesh + fig.add_trace(go.Mesh3d( + x=x_coords, + y=y_coords, + z=z_coords, + i=[f[0] for f in faces], + j=[f[1] for f in faces], + k=[f[2] for f in faces], + color=props.color, + opacity=props.opacity, + name=props.name or f"Region_{id(rl)}", + showlegend=True, + lighting=dict(ambient=0.6, diffuse=0.8, specular=0.2), + lightposition=dict(x=100, y=200, z=0) + )) + + # Add edge lines if requested + if props.show_edges: + edges = [ + # Bottom face edges + ([props.x_min, props.x_max], [props.y_min, props.y_min], [props.z_min, props.z_min]), + ([props.x_max, props.x_max], [props.y_min, props.y_max], [props.z_min, props.z_min]), + ([props.x_max, props.x_min], [props.y_max, props.y_max], [props.z_min, props.z_min]), + ([props.x_min, props.x_min], [props.y_max, props.y_min], [props.z_min, props.z_min]), + # Top face edges + ([props.x_min, props.x_max], [props.y_min, props.y_min], [props.z_max, props.z_max]), + ([props.x_max, props.x_max], [props.y_min, props.y_max], [props.z_max, props.z_max]), + ([props.x_max, props.x_min], [props.y_max, props.y_max], [props.z_max, props.z_max]), + ([props.x_min, props.x_min], [props.y_max, props.y_min], [props.z_max, props.z_max]), + # Vertical edges + ([props.x_min, props.x_min], [props.y_min, props.y_min], [props.z_min, props.z_max]), + ([props.x_max, props.x_max], [props.y_min, props.y_min], [props.z_min, props.z_max]), + ([props.x_max, props.x_max], [props.y_max, props.y_max], [props.z_min, props.z_max]), + ([props.x_min, props.x_min], [props.y_max, props.y_max], [props.z_min, props.z_max]), + ] + + for i, (x_edge, y_edge, z_edge) in enumerate(edges): + fig.add_trace(go.Scatter3d( + x=x_edge, + y=y_edge, + z=z_edge, + mode='lines', + line=dict(color=props.edge_color, width=props.edge_width), + name=f"{props.name}_edge_{i}" if props.name else f"RegionEdge_{i}", + showlegend=False, + hoverinfo='skip' + )) + @pyaedt_function_handler() def plot_2d(self, traces=None, snapshot_path=None, show=True): """Create a 2D Plotly plot for the given traces.""" @@ -515,6 +966,7 @@ def plot_2d(self, traces=None, snapshot_path=None, show=True): width=self.size[0], height=self.size[1] ) + self._add_limit_to_fig(self.fig, plot_type="2d") if snapshot_path: self.fig.write_image(snapshot_path) @@ -588,6 +1040,7 @@ def plot_polar(self, traces=None, snapshot_path=None, show=True): width=self.size[0], height=self.size[1] ) + self._add_limit_to_fig(self.fig, plot_type="polar") if snapshot_path: self.fig.write_image(snapshot_path) @@ -598,54 +1051,54 @@ def plot_polar(self, traces=None, snapshot_path=None, show=True): return self.fig @pyaedt_function_handler() - def plot_3d(self, trace=0, snapshot_path=None, show=True): - """Create a 3D Plotly plot for the given trace.""" - traces_to_plot = self._retrieve_traces(trace) + def plot_3d(self, traces=None, snapshot_path=None, show=True): + """Create a 3D Plotly plot for the given traces.""" + traces_to_plot = self._retrieve_traces(traces) if not traces_to_plot: return None - trace_obj = traces_to_plot[0] + self.fig = go.Figure() - if not trace_obj.cartesian_data or len(trace_obj.cartesian_data) < 3: - return None + for trace_obj in traces_to_plot: + if not trace_obj.cartesian_data or len(trace_obj.cartesian_data) < 3: + continue - x_data = trace_obj.cartesian_data[0] - y_data = trace_obj.cartesian_data[1] - z_data = trace_obj.cartesian_data[2] + x_data = trace_obj.cartesian_data[0] + y_data = trace_obj.cartesian_data[1] + z_data = trace_obj.cartesian_data[2] - self.fig = go.Figure() + marker_symbol = ( + trace_obj.marker_symbol.value + if isinstance(trace_obj.marker_symbol, MarkerSymbol) + else trace_obj.marker_symbol + ) - # Handle enum values properly - marker_symbol = ( - trace_obj.marker_symbol.value - if isinstance(trace_obj.marker_symbol, MarkerSymbol) - else trace_obj.marker_symbol - ) + self.fig.add_trace(go.Scatter3d( + x=x_data, + y=y_data, + z=z_data, + mode='lines+markers' if trace_obj.fill_symbol else 'lines', + name=trace_obj.name, + line=dict( + color=trace_obj.line_color, + width=trace_obj.line_width + ), + marker=dict( + symbol=marker_symbol, + size=trace_obj.marker_size, + color=trace_obj.marker_color + ) if trace_obj.fill_symbol else None + )) - self.fig.add_trace(go.Scatter3d( - x=x_data, - y=y_data, - z=z_data, - mode='lines+markers' if trace_obj.fill_symbol else 'lines', - name=trace_obj.name, - line=dict( - color=trace_obj.line_color, - width=trace_obj.line_width - ), - marker=dict( - symbol=marker_symbol, - size=trace_obj.marker_size, - color=trace_obj.marker_color - ) if trace_obj.fill_symbol else None - )) + # Use labels from the first valid trace + first_valid = next((t for t in traces_to_plot if t.cartesian_data and len(t.cartesian_data) >= 3), None) - # Update layout for 3D plot self.fig.update_layout( title=self.title, scene=dict( - xaxis_title=trace_obj.x_label, - yaxis_title=trace_obj.y_label, - zaxis_title=trace_obj.z_label, + xaxis_title=first_valid.x_label if first_valid else "", + yaxis_title=first_valid.y_label if first_valid else "", + zaxis_title=first_valid.z_label if first_valid else "", bgcolor=self.plot_color ), showlegend=self.show_legend, @@ -653,6 +1106,8 @@ def plot_3d(self, trace=0, snapshot_path=None, show=True): width=self.size[0], height=self.size[1] ) + self._add_limit_to_fig(self.fig, plot_type="3d") + self._add_region_limits_to_fig(self.fig, plot_type="3d") if snapshot_path: self.fig.write_image(snapshot_path) @@ -700,6 +1155,7 @@ def plot_contour(self, trace=0, levels=10, snapshot_path=None, show=True): width=self.size[0], height=self.size[1] ) + self._add_limit_to_fig(self.fig, plot_type="contour") if snapshot_path: self.fig.write_image(snapshot_path) From 4bacef33f4e5819915efbb02081c23fec2bf6001 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Thu, 31 Jul 2025 13:54:48 +0200 Subject: [PATCH 3/6] FEAT: Added notes into 2D plots --- .../aedt/core/visualization/plot/plotly.py | 227 +++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/src/ansys/aedt/core/visualization/plot/plotly.py b/src/ansys/aedt/core/visualization/plot/plotly.py index 41267770805..6424a33aa56 100644 --- a/src/ansys/aedt/core/visualization/plot/plotly.py +++ b/src/ansys/aedt/core/visualization/plot/plotly.py @@ -88,6 +88,27 @@ class MarkerSymbol(Enum): TRIANGLE_DOWN = "triangle-down" +@dataclass +class NoteProperties: + """Properties for customizing a Plotly note/annotation.""" + + text: str = "" + position: Tuple[float, float] = (0, 0) + background_color: Optional[str] = None + background_visibility: bool = True + border_visibility: bool = True + border_width: float = 1 + border_color: str = "black" + font_family: str = "Arial" + font_size: float = 12 + italic: bool = False + bold: bool = False + color: str = "black" + arrow_visible: bool = False + arrow_color: str = "black" + arrow_width: float = 1 + + @dataclass class TraceProperties: """Properties for customizing a Plotly trace.""" @@ -316,6 +337,13 @@ def __init__(self, properties: RegionLimitProperties): self.name = properties.name or f"RegionLimit_{id(self)}" +class PlotlyNote: + """A note/annotation for Plotly plots.""" + def __init__(self, properties: NoteProperties): + self.properties = properties + self.name = f"Note_{id(self)}" + + class PlotlyPlotter: """Manager for creating and displaying Plotly plots.""" @@ -334,7 +362,7 @@ def __init__( self._traces: Dict[str, PlotlyTrace] = {} self._limit_lines: Dict[str, Any] = {} self._region_limits: Dict[str, Any] = {} - self._notes: List[str] = [] + self._notes: Dict[str, PlotlyNote] = {} self.title = title self.show_legend = show_legend self.size = size @@ -389,6 +417,16 @@ def y_scale(self) -> ScaleType: def y_scale(self, value: Union[ScaleType, str]) -> None: self._y_scale = self._validate_scale(value) + @property + def notes(self) -> Dict[str, PlotlyNote]: + """All notes in the plot.""" + return self._notes + + @property + def note_names(self) -> List[str]: + """Names of all notes.""" + return list(self._notes.keys()) + @pyaedt_function_handler() def add_trace( self, @@ -561,6 +599,134 @@ def add_region_limit( self._region_limits[region_limit.name] = region_limit return True + @pyaedt_function_handler() + def add_note( + self, + text: Optional[str] = None, + position: Tuple[float, float] = (0, 0), + properties: Optional[NoteProperties] = None, + background_color: Optional[str] = None, + background_visibility: bool = True, + border_visibility: bool = True, + border_width: float = 1, + border_color: str = "black", + font_family: str = "Arial", + font_size: float = 12, + italic: bool = False, + bold: bool = False, + color: str = "black", + arrow_visible: bool = False, + arrow_color: str = "black", + arrow_width: float = 1, + ): + """Add a note/annotation to the plot. + + Parameters + ---------- + text : str, optional + The text content of the note. Required when properties is None. + If both text and properties are provided, text overrides the text in properties. + position : tuple, optional + Position of the note as (x, y) coordinates. Default is (0, 0). + Ignored when properties is provided. + properties : NoteProperties, optional + Note properties object. If provided, individual parameters are ignored + (except text, which overrides the text in properties). + background_color : str, optional + Background color of the note. Default is None (transparent). + Ignored when properties is provided. + background_visibility : bool, optional + Whether to show the background. Default is True. + Ignored when properties is provided. + border_visibility : bool, optional + Whether to show the border. Default is True. + Ignored when properties is provided. + border_width : float, optional + Width of the border. Default is 1. + Ignored when properties is provided. + border_color : str, optional + Color of the border. Default is "black". + Ignored when properties is provided. + font_family : str, optional + Font family for the text. Default is "Arial". + Ignored when properties is provided. + font_size : float, optional + Font size for the text. Default is 12. + Ignored when properties is provided. + italic : bool, optional + Whether to use italic font. Default is False. + Ignored when properties is provided. + bold : bool, optional + Whether to use bold font. Default is False. + Ignored when properties is provided. + color : str, optional + Text color. Default is "black". + Ignored when properties is provided. + arrow_visible : bool, optional + Whether to show an arrow pointing to the note. Default is False. + Ignored when properties is provided. + arrow_color : str, optional + Color of the arrow. Default is "black". + Ignored when properties is provided. + arrow_width : float, optional + Width of the arrow. Default is 1. + Ignored when properties is provided. + + Returns + ------- + bool + True if successful. + """ + if properties is None: + # When no properties object is provided, text is required + if text is None: + raise ValueError("Text is required when properties object is not provided") + props = NoteProperties( + text=text, + position=position, + background_color=background_color, + background_visibility=background_visibility, + border_visibility=border_visibility, + border_width=border_width, + border_color=border_color, + font_family=font_family, + font_size=font_size, + italic=italic, + bold=bold, + color=color, + arrow_visible=arrow_visible, + arrow_color=arrow_color, + arrow_width=arrow_width, + ) + else: + # When properties object is provided, use it directly + # If text is also provided, it overrides the text in properties + if text is not None: + # Create a copy of properties with overridden text + props = NoteProperties( + text=text, + position=properties.position, + background_color=properties.background_color, + background_visibility=properties.background_visibility, + border_visibility=properties.border_visibility, + border_width=properties.border_width, + border_color=properties.border_color, + font_family=properties.font_family, + font_size=properties.font_size, + italic=properties.italic, + bold=properties.bold, + color=properties.color, + arrow_visible=properties.arrow_visible, + arrow_color=properties.arrow_color, + arrow_width=properties.arrow_width, + ) + else: + # Use properties as-is + props = properties + note = PlotlyNote(props) + self._notes[note.name] = note + return True + @property def limit_lines(self): """All limit lines in the plot.""" @@ -902,6 +1068,61 @@ def _add_region_limits_to_fig(self, fig, plot_type="3d"): hoverinfo='skip' )) + def _add_notes_to_fig(self, fig, plot_type="2d"): + """Internal: Add notes/annotations to the figure.""" + if plot_type == "3d": + # Notes are not supported in 3D plots + if self._notes: + raise ValueError("Notes are not supported in 3D plots. Use 2D or polar plots for annotations.") + return + + for note in self._notes.values(): + props = note.properties + + # Create annotation dict for Plotly + annotation = dict( + text=props.text, + x=props.position[0], + y=props.position[1], + xref="x", + yref="y", + showarrow=props.arrow_visible, + font=dict( + family=props.font_family, + size=props.font_size, + color=props.color + ) + ) + + # Add font style + if props.bold and props.italic: + annotation["font"]["family"] = f"{props.font_family}, bold, italic" + elif props.bold: + annotation["font"]["family"] = f"{props.font_family}, bold" + elif props.italic: + annotation["font"]["family"] = f"{props.font_family}, italic" + + # Add arrow properties if visible + if props.arrow_visible: + annotation.update(dict( + arrowcolor=props.arrow_color, + arrowwidth=props.arrow_width, + arrowhead=2 + )) + + # Add background/border styling if visible + if props.background_visibility and props.background_color: + annotation["bgcolor"] = props.background_color + + if props.border_visibility: + annotation.update(dict( + bordercolor=props.border_color, + borderwidth=props.border_width + )) + + # Add annotation to figure + fig.add_annotation(annotation) + @pyaedt_function_handler() def plot_2d(self, traces=None, snapshot_path=None, show=True): """Create a 2D Plotly plot for the given traces.""" @@ -967,6 +1188,7 @@ def plot_2d(self, traces=None, snapshot_path=None, show=True): height=self.size[1] ) self._add_limit_to_fig(self.fig, plot_type="2d") + self._add_notes_to_fig(self.fig, plot_type="2d") if snapshot_path: self.fig.write_image(snapshot_path) @@ -1041,6 +1263,7 @@ def plot_polar(self, traces=None, snapshot_path=None, show=True): height=self.size[1] ) self._add_limit_to_fig(self.fig, plot_type="polar") + self._add_notes_to_fig(self.fig, plot_type="polar") if snapshot_path: self.fig.write_image(snapshot_path) @@ -1108,6 +1331,7 @@ def plot_3d(self, traces=None, snapshot_path=None, show=True): ) self._add_limit_to_fig(self.fig, plot_type="3d") self._add_region_limits_to_fig(self.fig, plot_type="3d") + self._add_notes_to_fig(self.fig, plot_type="3d") if snapshot_path: self.fig.write_image(snapshot_path) @@ -1156,6 +1380,7 @@ def plot_contour(self, trace=0, levels=10, snapshot_path=None, show=True): height=self.size[1] ) self._add_limit_to_fig(self.fig, plot_type="contour") + self._add_notes_to_fig(self.fig, plot_type="contour") if snapshot_path: self.fig.write_image(snapshot_path) From 992865d992b550a701d614a600ac74ca67fb4e6e Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Thu, 31 Jul 2025 16:11:22 +0200 Subject: [PATCH 4/6] FEAT: Implemented conversion methods between Cartesian, spherical, and polar coordinates in PlotlyTrace and PlotlyPlotter --- .../aedt/core/visualization/plot/plotly.py | 121 ++++++++++++++++-- 1 file changed, 107 insertions(+), 14 deletions(-) diff --git a/src/ansys/aedt/core/visualization/plot/plotly.py b/src/ansys/aedt/core/visualization/plot/plotly.py index 6424a33aa56..47ad0ca3ba7 100644 --- a/src/ansys/aedt/core/visualization/plot/plotly.py +++ b/src/ansys/aedt/core/visualization/plot/plotly.py @@ -25,6 +25,7 @@ from dataclasses import dataclass from dataclasses import field from enum import Enum +import math from typing import Any from typing import Dict from typing import List @@ -283,6 +284,82 @@ def spherical_data(self, val: List[Any]) -> None: else: self._spherical_data.append(np.array(el, dtype=float)) + @pyaedt_function_handler() + def car2spherical(self): + """Convert cartesian data to spherical and assigns to property spherical data.""" + if self._cartesian_data is None or len(self._cartesian_data) < 3: + return + + x = np.array(self._cartesian_data[0], dtype=float) + y = np.array(self._cartesian_data[1], dtype=float) + z = np.array(self._cartesian_data[2], dtype=float) + r = np.sqrt(x * x + y * y + z * z) + theta = np.arccos(z / r) * 180 / math.pi # to degrees + phi = np.arctan2(y, x) * 180 / math.pi + self._spherical_data = [r, theta, phi] + + @pyaedt_function_handler() + def spherical2car(self): + """Convert spherical data to cartesian data and assign to cartesian data property.""" + if self._spherical_data is None or len(self._spherical_data) < 3: + return + + r = np.array(self._spherical_data[0], dtype=float) + theta = np.array(self._spherical_data[1] * math.pi / 180, dtype=float) # to radian + phi = np.array(self._spherical_data[2] * math.pi / 180, dtype=float) + x = r * np.sin(theta) * np.cos(phi) + y = r * np.sin(theta) * np.sin(phi) + z = r * np.cos(theta) + self._cartesian_data = [x, y, z] + + @pyaedt_function_handler() + def car2polar(self, x, y, is_degree=False): + """Convert cartesian data to polar. + + Parameters + ---------- + x : array-like + X coordinates. + y : array-like + Y coordinates. + is_degree : bool, optional + Whether angles are in degrees. Default is False (radians). + + Returns + ------- + list + List of [r, theta]. + """ + x = np.array(x, dtype=float) + y = np.array(y, dtype=float) + r = np.sqrt(x**2 + y**2) + theta = np.arctan2(y, x) + if is_degree: + theta = theta * 180 / math.pi + return [r, theta] + + @pyaedt_function_handler() + def polar2car(self, r, theta): + """Convert polar data to cartesian data. + + Parameters + ---------- + r : array-like + Radial coordinates. + theta : array-like + Angular coordinates in degrees. + + Returns + ------- + list + List of [x, y]. + """ + r = np.array(r, dtype=float) + theta = np.array(theta, dtype=float) + x = r * np.cos(np.radians(theta)) + y = r * np.sin(np.radians(theta)) + return [x, y] + def is_notebook(): """Return True if running in a Jupyter notebook.""" @@ -1123,6 +1200,18 @@ def _add_notes_to_fig(self, fig, plot_type="2d"): # Add annotation to figure fig.add_annotation(annotation) + @pyaedt_function_handler() + def _get_cartesian_data(self, trace): + """Get cartesian data for a trace, converting from spherical if necessary.""" + if trace.cartesian_data is not None: + return trace.cartesian_data + elif trace.spherical_data is not None: + # Convert spherical to cartesian temporarily for plotting + trace.spherical2car() + return trace.cartesian_data + else: + return None + @pyaedt_function_handler() def plot_2d(self, traces=None, snapshot_path=None, show=True): """Create a 2D Plotly plot for the given traces.""" @@ -1133,9 +1222,10 @@ def plot_2d(self, traces=None, snapshot_path=None, show=True): self.fig = go.Figure() for trace in traces_to_plot: - if trace.cartesian_data and len(trace.cartesian_data) >= 2: - x_data = trace.cartesian_data[0] - y_data = trace.cartesian_data[1] + cartesian_data = self._get_cartesian_data(trace) + if cartesian_data and len(cartesian_data) >= 2: + x_data = cartesian_data[0] + y_data = cartesian_data[1] # Handle enum values properly line_style = ( @@ -1208,9 +1298,10 @@ def plot_polar(self, traces=None, snapshot_path=None, show=True): self.fig = go.Figure() for trace in traces_to_plot: - if trace.cartesian_data and len(trace.cartesian_data) >= 2: - theta = trace.cartesian_data[0] # Assuming theta in degrees - r = trace.cartesian_data[1] + cartesian_data = self._get_cartesian_data(trace) + if cartesian_data and len(cartesian_data) >= 2: + theta = cartesian_data[0] # Assuming theta in degrees + r = cartesian_data[1] # Handle enum values properly line_style = ( @@ -1283,12 +1374,13 @@ def plot_3d(self, traces=None, snapshot_path=None, show=True): self.fig = go.Figure() for trace_obj in traces_to_plot: - if not trace_obj.cartesian_data or len(trace_obj.cartesian_data) < 3: + cartesian_data = self._get_cartesian_data(trace_obj) + if not cartesian_data or len(cartesian_data) < 3: continue - x_data = trace_obj.cartesian_data[0] - y_data = trace_obj.cartesian_data[1] - z_data = trace_obj.cartesian_data[2] + x_data = cartesian_data[0] + y_data = cartesian_data[1] + z_data = cartesian_data[2] marker_symbol = ( trace_obj.marker_symbol.value @@ -1350,12 +1442,13 @@ def plot_contour(self, trace=0, levels=10, snapshot_path=None, show=True): trace_obj = traces_to_plot[0] - if not trace_obj.cartesian_data or len(trace_obj.cartesian_data) < 3: + cartesian_data = self._get_cartesian_data(trace_obj) + if not cartesian_data or len(cartesian_data) < 3: return None - x_data = trace_obj.cartesian_data[0] - y_data = trace_obj.cartesian_data[1] - z_data = trace_obj.cartesian_data[2] + x_data = cartesian_data[0] + y_data = cartesian_data[1] + z_data = cartesian_data[2] self.fig = go.Figure() From 2e875832629b93a921f7a4b6c908627811473cb0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:31:50 +0000 Subject: [PATCH 5/6] CHORE: Auto fixes from pre-commit hooks --- .../aedt/core/visualization/plot/plotly.py | 582 +++++++++--------- 1 file changed, 295 insertions(+), 287 deletions(-) diff --git a/src/ansys/aedt/core/visualization/plot/plotly.py b/src/ansys/aedt/core/visualization/plot/plotly.py index 47ad0ca3ba7..e31c686083a 100644 --- a/src/ansys/aedt/core/visualization/plot/plotly.py +++ b/src/ansys/aedt/core/visualization/plot/plotly.py @@ -36,9 +36,7 @@ import numpy as np -from ansys.aedt.core.generic.general_methods import ( - pyaedt_function_handler, -) +from ansys.aedt.core.generic.general_methods import pyaedt_function_handler from ansys.aedt.core.internal.checks import ERROR_GRAPHICS_REQUIRED from ansys.aedt.core.internal.checks import check_graphics_available @@ -92,7 +90,7 @@ class MarkerSymbol(Enum): @dataclass class NoteProperties: """Properties for customizing a Plotly note/annotation.""" - + text: str = "" position: Tuple[float, float] = (0, 0) background_color: Optional[str] = None @@ -262,9 +260,7 @@ def cartesian_data(self, val: List[Any]) -> None: # Automatically add z=0 for 2D data if len(self._cartesian_data) == 2: - self._cartesian_data.append( - np.zeros(len(self._cartesian_data[-1])) - ) + self._cartesian_data.append(np.zeros(len(self._cartesian_data[-1]))) @property def spherical_data(self) -> Optional[List[np.ndarray]]: @@ -289,7 +285,7 @@ def car2spherical(self): """Convert cartesian data to spherical and assigns to property spherical data.""" if self._cartesian_data is None or len(self._cartesian_data) < 3: return - + x = np.array(self._cartesian_data[0], dtype=float) y = np.array(self._cartesian_data[1], dtype=float) z = np.array(self._cartesian_data[2], dtype=float) @@ -303,7 +299,7 @@ def spherical2car(self): """Convert spherical data to cartesian data and assign to cartesian data property.""" if self._spherical_data is None or len(self._spherical_data) < 3: return - + r = np.array(self._spherical_data[0], dtype=float) theta = np.array(self._spherical_data[1] * math.pi / 180, dtype=float) # to radian phi = np.array(self._spherical_data[2] * math.pi / 180, dtype=float) @@ -320,7 +316,7 @@ def car2polar(self, x, y, is_degree=False): ---------- x : array-like X coordinates. - y : array-like + y : array-like Y coordinates. is_degree : bool, optional Whether angles are in degrees. Default is False (radians). @@ -372,6 +368,7 @@ def is_notebook(): class LimitLineProperties: """Properties for customizing a Plotly limit line.""" + def __init__(self, x=None, y=None, orientation="h", color="red", width=2, dash="dash", name=""): self.x = x self.y = y @@ -384,6 +381,7 @@ def __init__(self, x=None, y=None, orientation="h", color="red", width=2, dash=" class PlotlyLimitLine: """A limit line for Plotly plots.""" + def __init__(self, properties: LimitLineProperties): self.properties = properties self.name = properties.name or f"LimitLine_{id(self)}" @@ -391,8 +389,22 @@ def __init__(self, properties: LimitLineProperties): class RegionLimitProperties: """Properties for customizing a 3D region limit.""" - def __init__(self, x_min=None, x_max=None, y_min=None, y_max=None, z_min=None, z_max=None, - color="lightblue", opacity=0.3, name="", show_edges=True, edge_color="blue", edge_width=2): + + def __init__( + self, + x_min=None, + x_max=None, + y_min=None, + y_max=None, + z_min=None, + z_max=None, + color="lightblue", + opacity=0.3, + name="", + show_edges=True, + edge_color="blue", + edge_width=2, + ): self.x_min = x_min self.x_max = x_max self.y_min = y_min @@ -409,6 +421,7 @@ def __init__(self, x_min=None, x_max=None, y_min=None, y_max=None, z_min=None, z class PlotlyRegionLimit: """A region limit for 3D Plotly plots.""" + def __init__(self, properties: RegionLimitProperties): self.properties = properties self.name = properties.name or f"RegionLimit_{id(self)}" @@ -416,6 +429,7 @@ def __init__(self, properties: RegionLimitProperties): class PlotlyNote: """A note/annotation for Plotly plots.""" + def __init__(self, properties: NoteProperties): self.properties = properties self.name = f"Note_{id(self)}" @@ -433,7 +447,7 @@ def __init__( size: Tuple[int, int] = (800, 600), grid_color: str = "lightgray", background_color: str = "white", - plot_color: str = "white" + plot_color: str = "white", ): """Initialize the PlotlyPlotter.""" self._traces: Dict[str, PlotlyTrace] = {} @@ -520,7 +534,7 @@ def add_trace( marker_symbol: Union[MarkerSymbol, str] = MarkerSymbol.CIRCLE, marker_size: float = 8.0, marker_color: Optional[str] = None, - fill_symbol: bool = False + fill_symbol: bool = False, ) -> bool: """Add a trace to the plot. @@ -546,7 +560,7 @@ def add_trace( marker_symbol=marker_symbol, marker_size=marker_size, marker_color=marker_color, - fill_symbol=fill_symbol + fill_symbol=fill_symbol, ) elif isinstance(properties, TraceProperties): # Use provided TraceProperties dataclass @@ -563,13 +577,10 @@ def add_trace( marker_symbol=properties.get("marker_symbol", marker_symbol), marker_size=properties.get("marker_size", marker_size), marker_color=properties.get("marker_color", marker_color), - fill_symbol=properties.get("fill_symbol", fill_symbol) + fill_symbol=properties.get("fill_symbol", fill_symbol), ) else: - raise TypeError( - f"Properties must be TraceProperties, dict, or None. " - f"Got {type(properties)}" - ) + raise TypeError(f"Properties must be TraceProperties, dict, or None. Got {type(properties)}") # Create trace with the properties trace = PlotlyTrace(name=trace_name, properties=trace_props) @@ -612,20 +623,12 @@ def _retrieve_traces(self, traces): return [] @pyaedt_function_handler() - def add_limit( - self, - x=None, - y=None, - orientation="h", - color="red", - width=2, - dash="dash", - name="", - properties=None - ): + def add_limit(self, x=None, y=None, orientation="h", color="red", width=2, dash="dash", name="", properties=None): """Add a limit line or plane to the plot.""" if properties is None: - props = LimitLineProperties(x=x, y=y, orientation=orientation, color=color, width=width, dash=dash, name=name) + props = LimitLineProperties( + x=x, y=y, orientation=orientation, color=color, width=width, dash=dash, name=name + ) else: props = properties limit_line = PlotlyLimitLine(props) @@ -635,12 +638,22 @@ def add_limit( @pyaedt_function_handler() def add_region_limit( self, - x_min=None, x_max=None, y_min=None, y_max=None, z_min=None, z_max=None, - color="lightblue", opacity=0.3, name="", show_edges=True, edge_color="blue", edge_width=2, - properties=None + x_min=None, + x_max=None, + y_min=None, + y_max=None, + z_min=None, + z_max=None, + color="lightblue", + opacity=0.3, + name="", + show_edges=True, + edge_color="blue", + edge_width=2, + properties=None, ): """Add a 3D region limit (bounding box) to the plot. - + Parameters ---------- x_min, x_max : float, optional @@ -666,9 +679,18 @@ def add_region_limit( """ if properties is None: props = RegionLimitProperties( - x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, z_min=z_min, z_max=z_max, - color=color, opacity=opacity, name=name, show_edges=show_edges, - edge_color=edge_color, edge_width=edge_width + x_min=x_min, + x_max=x_max, + y_min=y_min, + y_max=y_max, + z_min=z_min, + z_max=z_max, + color=color, + opacity=opacity, + name=name, + show_edges=show_edges, + edge_color=edge_color, + edge_width=edge_width, ) else: props = properties @@ -837,7 +859,7 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): line_width=props.width, line_dash=props.dash, annotation_text=props.name if props.name else None, - annotation_position="top right" if props.name else None + annotation_position="top right" if props.name else None, ) elif props.orientation == "v" and props.x is not None: # Add infinite vertical line @@ -847,7 +869,7 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): line_width=props.width, line_dash=props.dash, annotation_text=props.name if props.name else None, - annotation_position="top right" if props.name else None + annotation_position="top right" if props.name else None, ) elif plot_type == "polar": # Add limit lines as Scatterpolar traces @@ -855,36 +877,40 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): # Constant radius (r) theta = np.linspace(0, 360, 361) r = np.full_like(theta, props.y) - fig.add_trace(go.Scatterpolar( - r=r, - theta=theta, - mode='lines', - name=props.name or f"r={props.y}", - line=dict(color=props.color, width=props.width, dash=props.dash), - showlegend=True - )) + fig.add_trace( + go.Scatterpolar( + r=r, + theta=theta, + mode="lines", + name=props.name or f"r={props.y}", + line=dict(color=props.color, width=props.width, dash=props.dash), + showlegend=True, + ) + ) elif props.orientation == "v" and props.x is not None: # Constant angle (theta) - extend radius from 0 to maximum visible radius # Get the maximum radius from existing traces or use a reasonable default max_radius = 10 # Default fallback try: - if hasattr(fig, 'data') and fig.data: + if hasattr(fig, "data") and fig.data: for trace in fig.data: - if hasattr(trace, 'r') and trace.r is not None: + if hasattr(trace, "r") and trace.r is not None: max_radius = max(max_radius, np.max(trace.r) * 1.2) except: pass - + r = np.linspace(0, max_radius, 100) theta = np.full_like(r, props.x) - fig.add_trace(go.Scatterpolar( - r=r, - theta=theta, - mode='lines', - name=props.name or f"theta={props.x}", - line=dict(color=props.color, width=props.width, dash=props.dash), - showlegend=True - )) + fig.add_trace( + go.Scatterpolar( + r=r, + theta=theta, + mode="lines", + name=props.name or f"theta={props.x}", + line=dict(color=props.color, width=props.width, dash=props.dash), + showlegend=True, + ) + ) elif plot_type == "3d": # For 3D plots, create infinite planes and lines if props.orientation == "h" and props.y is not None: @@ -892,18 +918,18 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): # Get the plot bounds to create a large enough plane x_range = [-10, 10] # Default range z_range = [-10, 10] - + # Try to get actual data bounds for better scaling try: - if hasattr(fig, 'data') and fig.data: + if hasattr(fig, "data") and fig.data: x_values = [] z_values = [] for trace in fig.data: - if hasattr(trace, 'x') and trace.x is not None: + if hasattr(trace, "x") and trace.x is not None: x_values.extend(trace.x) - if hasattr(trace, 'z') and trace.z is not None: + if hasattr(trace, "z") and trace.z is not None: z_values.extend(trace.z) - + if x_values: x_min, x_max = min(x_values), max(x_values) x_range = [x_min - abs(x_max - x_min), x_max + abs(x_max - x_min)] @@ -912,47 +938,51 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): z_range = [z_min - abs(z_max - z_min), z_max + abs(z_max - z_min)] except: pass - + # Create a grid for the plane x_plane = [x_range[0], x_range[1], x_range[1], x_range[0]] y_plane = [props.y, props.y, props.y, props.y] z_plane = [z_range[0], z_range[0], z_range[1], z_range[1]] - + # Define triangular faces for the plane i = [0, 0] # vertex indices j = [1, 2] k = [2, 3] - - fig.add_trace(go.Mesh3d( - x=x_plane, - y=y_plane, - z=z_plane, - i=i, j=j, k=k, - color=props.color, - opacity=0.4, - name=props.name or f"Y={props.y}", - showlegend=True, - flatshading=True, - lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), - lightposition=dict(x=100, y=200, z=0) - )) - + + fig.add_trace( + go.Mesh3d( + x=x_plane, + y=y_plane, + z=z_plane, + i=i, + j=j, + k=k, + color=props.color, + opacity=0.4, + name=props.name or f"Y={props.y}", + showlegend=True, + flatshading=True, + lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), + lightposition=dict(x=100, y=200, z=0), + ) + ) + elif props.orientation == "v" and props.x is not None: # Vertical plane at x = constant (infinite in y and z) y_range = [-10, 10] z_range = [-10, 10] - + # Try to get actual data bounds try: - if hasattr(fig, 'data') and fig.data: + if hasattr(fig, "data") and fig.data: y_values = [] z_values = [] for trace in fig.data: - if hasattr(trace, 'y') and trace.y is not None: + if hasattr(trace, "y") and trace.y is not None: y_values.extend(trace.y) - if hasattr(trace, 'z') and trace.z is not None: + if hasattr(trace, "z") and trace.z is not None: z_values.extend(trace.z) - + if y_values: y_min, y_max = min(y_values), max(y_values) y_range = [y_min - abs(y_max - y_min), y_max + abs(y_max - y_min)] @@ -961,48 +991,52 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): z_range = [z_min - abs(z_max - z_min), z_max + abs(z_max - z_min)] except: pass - + # Create a grid for the plane x_plane = [props.x, props.x, props.x, props.x] y_plane = [y_range[0], y_range[1], y_range[1], y_range[0]] z_plane = [z_range[0], z_range[0], z_range[1], z_range[1]] - + # Define triangular faces for the plane i = [0, 0] j = [1, 2] k = [2, 3] - - fig.add_trace(go.Mesh3d( - x=x_plane, - y=y_plane, - z=z_plane, - i=i, j=j, k=k, - color=props.color, - opacity=0.4, - name=props.name or f"X={props.x}", - showlegend=True, - flatshading=True, - lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), - lightposition=dict(x=100, y=200, z=0) - )) - + + fig.add_trace( + go.Mesh3d( + x=x_plane, + y=y_plane, + z=z_plane, + i=i, + j=j, + k=k, + color=props.color, + opacity=0.4, + name=props.name or f"X={props.x}", + showlegend=True, + flatshading=True, + lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), + lightposition=dict(x=100, y=200, z=0), + ) + ) + # Add support for Z-plane limits - elif hasattr(props, 'z') and props.z is not None: + elif hasattr(props, "z") and props.z is not None: # Horizontal plane at z = constant (infinite in x and y) x_range = [-10, 10] y_range = [-10, 10] - + # Try to get actual data bounds try: - if hasattr(fig, 'data') and fig.data: + if hasattr(fig, "data") and fig.data: x_values = [] y_values = [] for trace in fig.data: - if hasattr(trace, 'x') and trace.x is not None: + if hasattr(trace, "x") and trace.x is not None: x_values.extend(trace.x) - if hasattr(trace, 'y') and trace.y is not None: + if hasattr(trace, "y") and trace.y is not None: y_values.extend(trace.y) - + if x_values: x_min, x_max = min(x_values), max(x_values) x_range = [x_min - abs(x_max - x_min), x_max + abs(x_max - x_min)] @@ -1011,30 +1045,34 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): y_range = [y_min - abs(y_max - y_min), y_max + abs(y_max - y_min)] except: pass - + # Create a grid for the plane x_plane = [x_range[0], x_range[1], x_range[1], x_range[0]] y_plane = [y_range[0], y_range[0], y_range[1], y_range[1]] z_plane = [props.z, props.z, props.z, props.z] - + # Define triangular faces for the plane i = [0, 0] j = [1, 2] k = [2, 3] - - fig.add_trace(go.Mesh3d( - x=x_plane, - y=y_plane, - z=z_plane, - i=i, j=j, k=k, - color=props.color, - opacity=0.4, - name=props.name or f"Z={props.z}", - showlegend=True, - flatshading=True, - lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), - lightposition=dict(x=100, y=200, z=0) - )) + + fig.add_trace( + go.Mesh3d( + x=x_plane, + y=y_plane, + z=z_plane, + i=i, + j=j, + k=k, + color=props.color, + opacity=0.4, + name=props.name or f"Z={props.z}", + showlegend=True, + flatshading=True, + lighting=dict(ambient=0.9, diffuse=0.9, specular=0.1), + lightposition=dict(x=100, y=200, z=0), + ) + ) elif plot_type == "contour": # For contour plots, we can overlay lines on the 2D contour if props.orientation == "h" and props.y is not None: @@ -1043,7 +1081,7 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): line_color=props.color, line_width=props.width, line_dash=props.dash, - annotation_text=props.name if props.name else None + annotation_text=props.name if props.name else None, ) elif props.orientation == "v" and props.x is not None: fig.add_vline( @@ -1051,19 +1089,21 @@ def _add_limit_to_fig(self, fig, plot_type="2d"): line_color=props.color, line_width=props.width, line_dash=props.dash, - annotation_text=props.name if props.name else None + annotation_text=props.name if props.name else None, ) def _add_region_limits_to_fig(self, fig, plot_type="3d"): """Internal: Add region limits to the figure (3D only).""" if plot_type != "3d": return - + for rl in self._region_limits.values(): props = rl.properties - + # Create 3D bounding box - if all(v is not None for v in [props.x_min, props.x_max, props.y_min, props.y_max, props.z_min, props.z_max]): + if all( + v is not None for v in [props.x_min, props.x_max, props.y_min, props.y_max, props.z_min, props.z_max] + ): # Define the 8 vertices of the box vertices = [ [props.x_min, props.y_min, props.z_min], # 0 @@ -1075,44 +1115,52 @@ def _add_region_limits_to_fig(self, fig, plot_type="3d"): [props.x_max, props.y_max, props.z_max], # 6 [props.x_min, props.y_max, props.z_max], # 7 ] - + # Define the 12 triangular faces (2 triangles per face, 6 faces) faces = [ # Bottom face (z_min) - [0, 1, 2], [0, 2, 3], - # Top face (z_max) - [4, 6, 5], [4, 7, 6], + [0, 1, 2], + [0, 2, 3], + # Top face (z_max) + [4, 6, 5], + [4, 7, 6], # Front face (y_min) - [0, 4, 5], [0, 5, 1], + [0, 4, 5], + [0, 5, 1], # Back face (y_max) - [2, 6, 7], [2, 7, 3], + [2, 6, 7], + [2, 7, 3], # Left face (x_min) - [0, 3, 7], [0, 7, 4], + [0, 3, 7], + [0, 7, 4], # Right face (x_max) - [1, 5, 6], [1, 6, 2], + [1, 5, 6], + [1, 6, 2], ] - + # Extract coordinates x_coords = [v[0] for v in vertices] y_coords = [v[1] for v in vertices] z_coords = [v[2] for v in vertices] - + # Create mesh - fig.add_trace(go.Mesh3d( - x=x_coords, - y=y_coords, - z=z_coords, - i=[f[0] for f in faces], - j=[f[1] for f in faces], - k=[f[2] for f in faces], - color=props.color, - opacity=props.opacity, - name=props.name or f"Region_{id(rl)}", - showlegend=True, - lighting=dict(ambient=0.6, diffuse=0.8, specular=0.2), - lightposition=dict(x=100, y=200, z=0) - )) - + fig.add_trace( + go.Mesh3d( + x=x_coords, + y=y_coords, + z=z_coords, + i=[f[0] for f in faces], + j=[f[1] for f in faces], + k=[f[2] for f in faces], + color=props.color, + opacity=props.opacity, + name=props.name or f"Region_{id(rl)}", + showlegend=True, + lighting=dict(ambient=0.6, diffuse=0.8, specular=0.2), + lightposition=dict(x=100, y=200, z=0), + ) + ) + # Add edge lines if requested if props.show_edges: edges = [ @@ -1132,18 +1180,20 @@ def _add_region_limits_to_fig(self, fig, plot_type="3d"): ([props.x_max, props.x_max], [props.y_max, props.y_max], [props.z_min, props.z_max]), ([props.x_min, props.x_min], [props.y_max, props.y_max], [props.z_min, props.z_max]), ] - + for i, (x_edge, y_edge, z_edge) in enumerate(edges): - fig.add_trace(go.Scatter3d( - x=x_edge, - y=y_edge, - z=z_edge, - mode='lines', - line=dict(color=props.edge_color, width=props.edge_width), - name=f"{props.name}_edge_{i}" if props.name else f"RegionEdge_{i}", - showlegend=False, - hoverinfo='skip' - )) + fig.add_trace( + go.Scatter3d( + x=x_edge, + y=y_edge, + z=z_edge, + mode="lines", + line=dict(color=props.edge_color, width=props.edge_width), + name=f"{props.name}_edge_{i}" if props.name else f"RegionEdge_{i}", + showlegend=False, + hoverinfo="skip", + ) + ) def _add_notes_to_fig(self, fig, plot_type="2d"): """Internal: Add notes/annotations to the figure.""" @@ -1152,10 +1202,10 @@ def _add_notes_to_fig(self, fig, plot_type="2d"): if self._notes: raise ValueError("Notes are not supported in 3D plots. Use 2D or polar plots for annotations.") return - + for note in self._notes.values(): props = note.properties - + # Create annotation dict for Plotly annotation = dict( text=props.text, @@ -1164,39 +1214,28 @@ def _add_notes_to_fig(self, fig, plot_type="2d"): xref="x", yref="y", showarrow=props.arrow_visible, - font=dict( - family=props.font_family, - size=props.font_size, - color=props.color - ) + font=dict(family=props.font_family, size=props.font_size, color=props.color), ) - + # Add font style if props.bold and props.italic: annotation["font"]["family"] = f"{props.font_family}, bold, italic" elif props.bold: - annotation["font"]["family"] = f"{props.font_family}, bold" + annotation["font"]["family"] = f"{props.font_family}, bold" elif props.italic: annotation["font"]["family"] = f"{props.font_family}, italic" - + # Add arrow properties if visible if props.arrow_visible: - annotation.update(dict( - arrowcolor=props.arrow_color, - arrowwidth=props.arrow_width, - arrowhead=2 - )) - + annotation.update(dict(arrowcolor=props.arrow_color, arrowwidth=props.arrow_width, arrowhead=2)) + # Add background/border styling if visible if props.background_visibility and props.background_color: annotation["bgcolor"] = props.background_color - + if props.border_visibility: - annotation.update(dict( - bordercolor=props.border_color, - borderwidth=props.border_width - )) - + annotation.update(dict(bordercolor=props.border_color, borderwidth=props.border_width)) + # Add annotation to figure fig.add_annotation(annotation) @@ -1228,33 +1267,23 @@ def plot_2d(self, traces=None, snapshot_path=None, show=True): y_data = cartesian_data[1] # Handle enum values properly - line_style = ( - trace.line_style.value - if isinstance(trace.line_style, LineStyle) - else trace.line_style - ) + line_style = trace.line_style.value if isinstance(trace.line_style, LineStyle) else trace.line_style marker_symbol = ( - trace.marker_symbol.value - if isinstance(trace.marker_symbol, MarkerSymbol) - else trace.marker_symbol + trace.marker_symbol.value if isinstance(trace.marker_symbol, MarkerSymbol) else trace.marker_symbol ) - self.fig.add_trace(go.Scatter( - x=x_data, - y=y_data, - mode='lines+markers' if trace.fill_symbol else 'lines', - name=trace.name, - line=dict( - color=trace.line_color, - width=trace.line_width, - dash=line_style - ), - marker=dict( - symbol=marker_symbol, - size=trace.marker_size, - color=trace.marker_color - ) if trace.fill_symbol else None - )) + self.fig.add_trace( + go.Scatter( + x=x_data, + y=y_data, + mode="lines+markers" if trace.fill_symbol else "lines", + name=trace.name, + line=dict(color=trace.line_color, width=trace.line_width, dash=line_style), + marker=dict(symbol=marker_symbol, size=trace.marker_size, color=trace.marker_color) + if trace.fill_symbol + else None, + ) + ) # Update layout self.fig.update_layout( @@ -1263,19 +1292,19 @@ def plot_2d(self, traces=None, snapshot_path=None, show=True): title=traces_to_plot[0].x_label if traces_to_plot else "", type=self.x_scale.value, showgrid=self.grid_enable_major_x, - gridcolor=self.grid_color + gridcolor=self.grid_color, ), yaxis=dict( title=traces_to_plot[0].y_label if traces_to_plot else "", type=self.y_scale.value, showgrid=self.grid_enable_major_y, - gridcolor=self.grid_color + gridcolor=self.grid_color, ), showlegend=self.show_legend, plot_bgcolor=self.plot_color, paper_bgcolor=self.background_color, width=self.size[0], - height=self.size[1] + height=self.size[1], ) self._add_limit_to_fig(self.fig, plot_type="2d") self._add_notes_to_fig(self.fig, plot_type="2d") @@ -1304,33 +1333,23 @@ def plot_polar(self, traces=None, snapshot_path=None, show=True): r = cartesian_data[1] # Handle enum values properly - line_style = ( - trace.line_style.value - if isinstance(trace.line_style, LineStyle) - else trace.line_style - ) + line_style = trace.line_style.value if isinstance(trace.line_style, LineStyle) else trace.line_style marker_symbol = ( - trace.marker_symbol.value - if isinstance(trace.marker_symbol, MarkerSymbol) - else trace.marker_symbol + trace.marker_symbol.value if isinstance(trace.marker_symbol, MarkerSymbol) else trace.marker_symbol ) - self.fig.add_trace(go.Scatterpolar( - r=r, - theta=theta, - mode='lines+markers' if trace.fill_symbol else 'lines', - name=trace.name, - line=dict( - color=trace.line_color, - width=trace.line_width, - dash=line_style - ), - marker=dict( - symbol=marker_symbol, - size=trace.marker_size, - color=trace.marker_color - ) if trace.fill_symbol else None - )) + self.fig.add_trace( + go.Scatterpolar( + r=r, + theta=theta, + mode="lines+markers" if trace.fill_symbol else "lines", + name=trace.name, + line=dict(color=trace.line_color, width=trace.line_width, dash=line_style), + marker=dict(symbol=marker_symbol, size=trace.marker_size, color=trace.marker_color) + if trace.fill_symbol + else None, + ) + ) # Update layout for polar plot self.fig.update_layout( @@ -1340,18 +1359,15 @@ def plot_polar(self, traces=None, snapshot_path=None, show=True): visible=True, title=traces_to_plot[0].y_label if traces_to_plot else "", showgrid=self.grid_enable_major_y, - gridcolor=self.grid_color + gridcolor=self.grid_color, ), - angularaxis=dict( - direction="counterclockwise", - period=360 - ) + angularaxis=dict(direction="counterclockwise", period=360), ), showlegend=self.show_legend, plot_bgcolor=self.plot_color, paper_bgcolor=self.background_color, width=self.size[0], - height=self.size[1] + height=self.size[1], ) self._add_limit_to_fig(self.fig, plot_type="polar") self._add_notes_to_fig(self.fig, plot_type="polar") @@ -1388,22 +1404,19 @@ def plot_3d(self, traces=None, snapshot_path=None, show=True): else trace_obj.marker_symbol ) - self.fig.add_trace(go.Scatter3d( - x=x_data, - y=y_data, - z=z_data, - mode='lines+markers' if trace_obj.fill_symbol else 'lines', - name=trace_obj.name, - line=dict( - color=trace_obj.line_color, - width=trace_obj.line_width - ), - marker=dict( - symbol=marker_symbol, - size=trace_obj.marker_size, - color=trace_obj.marker_color - ) if trace_obj.fill_symbol else None - )) + self.fig.add_trace( + go.Scatter3d( + x=x_data, + y=y_data, + z=z_data, + mode="lines+markers" if trace_obj.fill_symbol else "lines", + name=trace_obj.name, + line=dict(color=trace_obj.line_color, width=trace_obj.line_width), + marker=dict(symbol=marker_symbol, size=trace_obj.marker_size, color=trace_obj.marker_color) + if trace_obj.fill_symbol + else None, + ) + ) # Use labels from the first valid trace first_valid = next((t for t in traces_to_plot if t.cartesian_data and len(t.cartesian_data) >= 3), None) @@ -1414,12 +1427,12 @@ def plot_3d(self, traces=None, snapshot_path=None, show=True): xaxis_title=first_valid.x_label if first_valid else "", yaxis_title=first_valid.y_label if first_valid else "", zaxis_title=first_valid.z_label if first_valid else "", - bgcolor=self.plot_color + bgcolor=self.plot_color, ), showlegend=self.show_legend, paper_bgcolor=self.background_color, width=self.size[0], - height=self.size[1] + height=self.size[1], ) self._add_limit_to_fig(self.fig, plot_type="3d") self._add_region_limits_to_fig(self.fig, plot_type="3d") @@ -1452,14 +1465,9 @@ def plot_contour(self, trace=0, levels=10, snapshot_path=None, show=True): self.fig = go.Figure() - self.fig.add_trace(go.Contour( - x=x_data, - y=y_data, - z=z_data, - name=trace_obj.name, - ncontours=levels, - colorscale='Viridis' - )) + self.fig.add_trace( + go.Contour(x=x_data, y=y_data, z=z_data, name=trace_obj.name, ncontours=levels, colorscale="Viridis") + ) # Update layout self.fig.update_layout( @@ -1470,7 +1478,7 @@ def plot_contour(self, trace=0, levels=10, snapshot_path=None, show=True): plot_bgcolor=self.plot_color, paper_bgcolor=self.background_color, width=self.size[0], - height=self.size[1] + height=self.size[1], ) self._add_limit_to_fig(self.fig, plot_type="contour") self._add_notes_to_fig(self.fig, plot_type="contour") @@ -1489,42 +1497,42 @@ def _show_plot(self, fig): if is_notebook(): fig.show() else: - from http.server import HTTPServer, SimpleHTTPRequestHandler - import socket, threading, time, webbrowser + from http.server import HTTPServer + from http.server import SimpleHTTPRequestHandler + import socket + import threading + import time + import webbrowser # Generate HTML content in memory - html_content = pyo.plot( - fig, output_type='div', include_plotlyjs=True - ) + html_content = pyo.plot(fig, output_type="div", include_plotlyjs=True) # Create a simple HTTP server to serve the content class PlotlyHandler(SimpleHTTPRequestHandler): def do_GET(self): - if self.path == '/' or self.path == '/plot.html': + if self.path == "/" or self.path == "/plot.html": self.send_response(200) - self.send_header('Content-type', 'text/html') + self.send_header("Content-type", "text/html") self.end_headers() - self.wfile.write(html_content.encode('utf-8')) + self.wfile.write(html_content.encode("utf-8")) else: self.send_error(404) # Start server on available port sock = socket.socket() - sock.bind(('', 0)) + sock.bind(("", 0)) port = sock.getsockname()[1] sock.close() - server = HTTPServer(('localhost', port), PlotlyHandler) + server = HTTPServer(("localhost", port), PlotlyHandler) # Start server in background thread - server_thread = threading.Thread( - target=server.serve_forever - ) + server_thread = threading.Thread(target=server.serve_forever) server_thread.daemon = True server_thread.start() # Open browser - webbrowser.open(f'http://localhost:{port}/plot.html') + webbrowser.open(f"http://localhost:{port}/plot.html") # Keep server running for a reasonable time then shut down def shutdown_server(): From 237b872effdc32e8b264a569d660749ea64ceb90 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:34:34 +0000 Subject: [PATCH 6/6] chore: adding changelog file 6490.added.md [dependabot-skip] --- doc/changelog.d/6490.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/6490.added.md diff --git a/doc/changelog.d/6490.added.md b/doc/changelog.d/6490.added.md new file mode 100644 index 00000000000..1413396b400 --- /dev/null +++ b/doc/changelog.d/6490.added.md @@ -0,0 +1 @@ +Plotly wrapper \ No newline at end of file