"""Contains the pyvista.Text class.""" from __future__ import annotations import pathlib from pathlib import Path from typing import TYPE_CHECKING from typing import Literal import pyvista from pyvista._deprecate_positional_args import _deprecate_positional_args from pyvista.core import _validation from pyvista.core._typing_core import BoundsTuple from pyvista.core.utilities.misc import _check_range from pyvista.core.utilities.misc import _NameMixin from pyvista.core.utilities.misc import _NoNewAttrMixin from . import _vtk from .colors import Color from .prop3d import _Prop3DMixin from .themes import Theme from .tools import FONTS if TYPE_CHECKING: from collections.abc import Sequence from pyvista.core._typing_core import VectorLike from ._typing import ColorLike HorizontalOptions = Literal['left', 'center', 'right'] VerticalOptions = Literal['bottom', 'center', 'top'] class CornerAnnotation( _NoNewAttrMixin, _vtk.DisableVtkSnakeCase, _NameMixin, _vtk.vtkCornerAnnotation ): """Text annotation in four corners. This is an annotation object that manages four text actors / mappers to provide annotation in the four corners of a viewport. Parameters ---------- position : str | bool Position of the text. text : str Text input. prop : pyvista.TextProperty, optional Text property. linear_font_scale_factor : float, optional Linear font scale factor. name : str, optional The name of this actor used when tracking on a plotter. .. versionadded:: 0.45 Examples -------- Create text annotation in four corners. >>> from pyvista import CornerAnnotation >>> text = CornerAnnotation(0, 'text') >>> prop = text.prop """ @_deprecate_positional_args(allowed=['position', 'text']) def __init__( # noqa: PLR0917 self, position, text, prop=None, linear_font_scale_factor=None, name=None ): """Initialize a new text annotation descriptor.""" super().__init__() self.set_text(position, text) if prop is None: self.prop = TextProperty() if linear_font_scale_factor is not None: self.linear_font_scale_factor = linear_font_scale_factor self._name = name def get_text(self, position): """Get the text to be displayed for each corner. Parameters ---------- position : str | bool Position of the text. Returns ------- str Text to be displayed for each corner. """ return self.GetText(position) def set_text(self, position, text): """Set the text to be displayed for each corner. Parameters ---------- position : str | bool Position of the text. text : str Text to be displayed for each corner. """ corner_mappings = { 'lower_left': self.LowerLeft, 'lower_right': self.LowerRight, 'upper_left': self.UpperLeft, 'upper_right': self.UpperRight, 'lower_edge': self.LowerEdge, 'upper_edge': self.UpperEdge, 'left_edge': self.LeftEdge, 'right_edge': self.RightEdge, } corner_mappings['ll'] = corner_mappings['lower_left'] corner_mappings['lr'] = corner_mappings['lower_right'] corner_mappings['ul'] = corner_mappings['upper_left'] corner_mappings['ur'] = corner_mappings['upper_right'] corner_mappings['top'] = corner_mappings['upper_edge'] corner_mappings['bottom'] = corner_mappings['lower_edge'] corner_mappings['right'] = corner_mappings['right_edge'] corner_mappings['r'] = corner_mappings['right_edge'] corner_mappings['left'] = corner_mappings['left_edge'] corner_mappings['l'] = corner_mappings['left_edge'] if isinstance(position, str): position = corner_mappings[position] elif position is True: position = corner_mappings['upper_left'] self.SetText(position, text) @property def prop(self) -> TextProperty: """Property of this actor. Returns ------- pyvista.TextProperty Property of this actor. """ return self.GetTextProperty() @prop.setter def prop(self, prop: TextProperty): self.SetTextProperty(prop) @property def linear_font_scale_factor(self) -> float: """Font scaling factors. Returns ------- float Font scaling factors. """ return self.GetLinearFontScaleFactor() @linear_font_scale_factor.setter def linear_font_scale_factor(self, factor: float): self.SetLinearFontScaleFactor(factor) class Text(_NoNewAttrMixin, _vtk.DisableVtkSnakeCase, _NameMixin, _vtk.vtkTextActor): r"""Define text by default theme. Parameters ---------- text : str, optional Text string to be displayed. "\n" is recognized as a carriage return/linefeed (line separator). The characters must be in the UTF-8 encoding. position : Sequence[float], optional The position coordinate. prop : pyvista.TextProperty, optional The property of this actor. name : str, optional The name of this actor used when tracking on a plotter. .. versionadded:: 0.45 Examples -------- Create a text with text's property. >>> from pyvista import Text >>> text = Text() >>> prop = text.prop """ @_deprecate_positional_args(allowed=['text']) def __init__( # noqa: PLR0917 self, text=None, position=None, prop=None, name=None ): """Initialize a new text descriptor.""" super().__init__() if text is not None: self.input = text if position is not None: self.position = position if prop is None: self.prop = TextProperty() self._name = name @property def input(self): r"""Text string to be displayed. Returns ------- str Text string to be displayed. "\n" is recognized as a carriage return/linefeed (line separator). The characters must be in the UTF-8 encoding. """ return self.GetInput() @input.setter def input(self, text: str): self.SetInput(text) @property def prop(self): """Property of this actor. Returns ------- pyvista.TextProperty Property of this actor. """ return self.GetTextProperty() @prop.setter def prop(self, prop: TextProperty): self.SetTextProperty(prop) @property def position(self): """Position coordinate. Returns ------- Sequence[float] Position coordinate. """ return self.GetPosition() @position.setter def position(self, position: Sequence[float]): self.SetPosition(position[0], position[1]) class Label(_Prop3DMixin, Text): """2D label actor with a 3D position coordinate. Unlike :class:`~pyvista.Text`, which uses 2D viewport coordinates to position text in a plot, this class instead uses a 3D position coordinate. This class may be positioned, oriented, and transformed in a manner similar to a 3D :class:`~pyvista.Actor`. In addition, this class supports an additional :attr:`relative_position` attribute. In general, it is recommended to simply use :attr:`~pyvista.Prop3D.position` when positioning a :class:`Label` by itself. However, if the position of the label depends on the positioning of another actor, both :attr:`~pyvista.Prop3D.position` and :attr:`relative_position` may be used together. In these cases, the :attr:`~pyvista.Prop3D.position` of the label and actor should be kept in-sync. See the examples below. Parameters ---------- text : str, optional Text string to be displayed. position : VectorLike[float] Position of the text in XYZ coordinates. relative_position : VectorLike[float] Position of the text in XYZ coordinates relative to its :attr:`~pyvista.Prop3D.position`. size : int Size of the text label. prop : pyvista.TextProperty, optional The property of this actor. name : str, optional The name of this actor used when tracking on a plotter. .. versionadded:: 0.45 See Also -------- pyvista.Plotter.add_point_labels Examples -------- Create a label for a point of interest. Here we add a label to the tip of a cone. >>> import pyvista as pv >>> cone_dataset = pv.Cone() >>> tip = (0.5, 0, 0) >>> label = pv.Label('tip', position=tip) Plot the mesh and label. >>> pl = pv.Plotter() >>> cone_actor = pl.add_mesh(cone_dataset) >>> _ = pl.add_actor(label) >>> pl.show() The previous example set the label's position as the cone's tip explicitly. However, this means that the two actors now have different positions. >>> cone_actor.position (0.0, 0.0, 0.0) >>> label.position (0.5, 0.0, 0.0) And if we change the 3D orientation of the cone and label, the label is no longer positioned at the tip. >>> cone_actor.orientation = 0, 0, 90 >>> label.orientation = 0, 0, 90 >>> >>> pl = pv.Plotter() >>> _ = pl.add_actor(cone_actor) >>> _ = pl.add_actor(label) >>> pl.show() This is because rotations by :class:`pyvista.Prop3D` are applied **before** the actor is moved to its final position, and therefore the label's position is not considered in the rotation. Hence, the final position of the label remains at ``(0.5, 0.0, 0.0)`` as it did earlier, despite changing its orientation. If we want the position of the label to have the same positioning *relative* to the cone, we can instead set its :attr:`relative_position`. First, reset the label's position to match the cone's position. >>> label.position = cone_actor.position >>> label.position (0.0, 0.0, 0.0) Now set its :attr:`relative_position` to the tip of the cone. >>> label.relative_position = tip >>> label.relative_position (0.5, 0.0, 0.0) Plot the results. The label is now correctly positioned at the tip of the cone. This is because the :attr:`relative_position` is considered as part of the rotation. >>> pl = pv.Plotter() >>> _ = pl.add_actor(cone_actor) >>> _ = pl.add_actor(label) >>> pl.show() As long as the label and cone's :class:`pyvista.Prop3D` attributes are modified together and synchronized, the label will remain at the tip of the cone. Modify the position of the label and tip. >>> cone_actor.position = (1.0, 2.0, 3.0) >>> label.position = (1.0, 2.0, 3.0) >>> pl = pv.Plotter() >>> _ = pl.add_actor(cone_actor) >>> _ = pl.add_actor(label) >>> _ = pl.add_axes_at_origin() >>> pl.show() """ def __init__( self, text: str | None = None, position: VectorLike[float] = (0.0, 0.0, 0.0), relative_position: VectorLike[float] = (0.0, 0.0, 0.0), *, size: int = 50, prop: pyvista.Property | None = None, name: str = 'Label', ): Text.__init__(self, text=text, prop=prop) self.GetPositionCoordinate().SetCoordinateSystemToWorld() self.SetTextScaleModeToNone() # Use font size to control size of text self._name = name _Prop3DMixin.__init__(self) self.relative_position = relative_position self.position = position self.size = size @property def _label_position(self) -> tuple[float, float, float]: # numpydoc ignore=RT01 """Position of the label in xyz space. This is the "true" position of the label. Internally this is loosely equal to :attr:`~pyvista.Prop3D.position` + :attr:`relative_position`. """ return self.GetPositionCoordinate().GetValue() @_label_position.setter def _label_position(self, position: VectorLike[float]): valid_position = _validation.validate_array3(position) self.GetPositionCoordinate().SetValue(valid_position) @property def size(self) -> int: # numpydoc ignore=RT01 """Size of the text label. Notes ----- The text property's font size used to control the size of the label. """ return self.prop.font_size @size.setter def size(self, size: int): self.prop.font_size = size @property def relative_position(self) -> tuple[float, float, float]: # numpydoc ignore=RT01 """Position of the label relative to its :attr:`~pyvista.Prop3D.position`.""" return tuple(self._relative_position.tolist()) @relative_position.setter def relative_position(self, position: VectorLike[float]): self._relative_position = _validation.validate_array3(position, dtype_out=float) self._post_set_update() def _post_set_update(self): # Update the label's underlying text position matrix4x4 = self._transformation_matrix vector4 = (*self.relative_position, 1) new_position = (matrix4x4 @ vector4)[:3] self._label_position = new_position def _get_bounds(self) -> BoundsTuple: # Define its 3D position as its bounds x, y, z = self._label_position return BoundsTuple(x, x, y, y, z, z) class TextProperty(_NoNewAttrMixin, _vtk.DisableVtkSnakeCase, _vtk.vtkTextProperty): """Define text's property. Parameters ---------- theme : pyvista.plotting.themes.Theme, optional Plot-specific theme. color : pyvista.ColorLike, optional Either a string, RGB list, or hex color string. For example: ``color='white'``, ``color='w'``, ``color=[1.0, 1.0, 1.0]``, or ``color='#FFFFFF'``. Color will be overridden if scalars are specified. font_family : str | None, optional Font family or None. orientation : float, optional Text's orientation (in degrees). font_size : int, optional Font size. font_file : str, optional Font file path. shadow : bool, optional If enable the shadow. justification_horizontal : str, optional Text's horizontal justification. Should be either "left", "center" or "right". justification_vertical : str, optional Text's vertical justification. Should be either "bottom", "center" or "top". italic : bool, default: False Italicises title and bar labels. bold : bool, default: True Bolds title and bar labels. background_color : pyvista.Color, optional Background color of text. background_opacity : pyvista.Color, optional Background opacity of text. Examples -------- Create a text's property. >>> from pyvista import TextProperty >>> prop = TextProperty() >>> prop.opacity = 0.5 >>> prop.background_color = 'b' >>> prop.background_opacity = 0.5 >>> prop.show_frame = True >>> prop.frame_color = 'b' >>> prop.frame_width = 10 >>> prop.frame_color Color(name='blue', hex='#0000ffff', opacity=255) """ _theme = Theme() _color_set = None _background_color_set = None _font_family = None @_deprecate_positional_args(allowed=['theme']) def __init__( # noqa: PLR0917 self, theme=None, color=None, font_family=None, orientation=None, font_size=None, font_file=None, shadow: bool = False, # noqa: FBT001, FBT002 justification_horizontal=None, justification_vertical=None, italic: bool = False, # noqa: FBT001, FBT002 bold: bool = False, # noqa: FBT001, FBT002 background_color=None, background_opacity=None, ): """Initialize text's property.""" super().__init__() if theme is None: # copy global theme to ensure local property theme is fixed # after creation. self._theme.load_theme(pyvista.global_theme) else: self._theme.load_theme(theme) self.color = color self.font_family = font_family if orientation is not None: self.orientation = orientation if font_size is not None: self.font_size = font_size if font_file is not None: self.set_font_file(font_file) if shadow: self.enable_shadow() if justification_horizontal is not None: self.justification_horizontal = justification_horizontal if justification_vertical is not None: self.justification_vertical = justification_vertical self.italic = italic self.bold = bold if background_color is not None: self.background_color = background_color if background_opacity is not None: self.background_opacity = background_opacity @property def color(self) -> Color: """Color of text's property. Returns ------- pyvista.Color Color of text's property. """ return Color(self.GetColor()) @color.setter def color(self, color: ColorLike): self._color_set = color is not None rgb_color = Color(color, default_color=self._theme.font.color) self.SetColor(rgb_color.float_rgb) @property def opacity(self) -> float: """Opacity of text's property. Returns ------- float Opacity of the text. A single float value that will be applied globally opacity of the text and uniformly applied everywhere. Between 0 and 1. """ return self.GetOpacity() @opacity.setter def opacity(self, opacity: float): _check_range(opacity, (0, 1), 'opacity') self.SetOpacity(opacity) @property def background_color(self) -> Color: """Background color of text's property. Returns ------- pyvista.Color Background color of text's property. """ return Color(self.GetBackgroundColor()) @background_color.setter def background_color(self, color: ColorLike): self._background_color_set = color is not None rgb_color = Color(color) self.SetBackgroundColor(rgb_color.float_rgb) @property def background_opacity(self) -> float: """Background opacity of text's property. Returns ------- float Background opacity of the text. A single float value that will be applied globally. Background opacity of the text and uniformly applied everywhere. Between 0 and 1. """ return self.GetBackgroundOpacity() @background_opacity.setter def background_opacity(self, opacity: float): _check_range(opacity, (0, 1), 'background_opacity') self.SetBackgroundOpacity(opacity) @property def show_frame(self) -> bool: """Visibility of frame. Returns ------- bool: If shows the frame. """ return bool(self.GetFrame()) @show_frame.setter def show_frame(self, frame: bool): self.SetFrame(frame) @property def frame_color(self) -> Color: """Frame color of text property. Returns ------- pyvista.Color Frame color of text property. """ return Color(self.GetFrameColor()) @frame_color.setter def frame_color(self, color): self.SetFrameColor(Color(color).float_rgb) @property def frame_width(self) -> int: """Width of the frame. Returns ------- int Width of the frame. The width is expressed in pixels. The default is 1 pixel. """ return self.GetFrameWidth() @frame_width.setter def frame_width(self, width: int): self.SetFrameWidth(width) @property def font_family(self) -> str | None: """Font family. Returns ------- str | None Font family or None. """ return self._font_family @font_family.setter def font_family(self, font_family: str | None): if font_family is None: font_family = self._theme.font.family self._font_family = font_family self.SetFontFamily(FONTS[self._font_family].value) @property def font_size(self) -> int: """Font size. Returns ------- int Font size. """ return self.GetFontSize() @font_size.setter def font_size(self, font_size: int): self.SetFontSize(font_size) def enable_shadow(self) -> None: """Enable the shadow.""" self.SetShadow(True) @property def orientation(self) -> float: """Text's orientation (in degrees). Returns ------- float Text's orientation (in degrees). """ return self.GetOrientation() @orientation.setter def orientation(self, orientation: float): self.SetOrientation(orientation) def set_font_file(self, font_file: str): """Set the font file. Parameters ---------- font_file : str Font file path. """ path = pathlib.Path(font_file) path = path.resolve() if not Path(path).is_file(): msg = f'Unable to locate {path}' raise FileNotFoundError(msg) self.SetFontFamily(_vtk.VTK_FONT_FILE) self.SetFontFile(str(path)) @property def justification_horizontal(self) -> str: """Text's justification horizontal. Returns ------- str Text's horizontal justification. Should be either "left", "center" or "right". """ justification = self.GetJustificationAsString().lower() if justification == 'centered': justification = 'center' return justification @justification_horizontal.setter def justification_horizontal(self, justification: str): if justification.lower() == 'left': self.SetJustificationToLeft() elif justification.lower() == 'center': self.SetJustificationToCentered() elif justification.lower() == 'right': self.SetJustificationToRight() else: msg = ( f'Invalid {justification} for justification_horizontal. ' 'Should be either "left", "center" or "right".' ) raise ValueError(msg) @property def justification_vertical(self) -> str: """Text's vertical justification. Returns ------- str Text's vertical justification. Should be either "bottom", "center" or "top". """ justification = self.GetVerticalJustificationAsString().lower() if justification == 'centered': justification = 'center' return justification @justification_vertical.setter def justification_vertical(self, justification: str): if justification.lower() == 'bottom': self.SetVerticalJustificationToBottom() elif justification.lower() == 'center': self.SetVerticalJustificationToCentered() elif justification.lower() == 'top': self.SetVerticalJustificationToTop() else: msg = ( f'Invalid {justification} for justification_vertical. ' 'Should be either "bottom", "center" or "top".' ) raise ValueError(msg) @property def italic(self) -> bool: """Italic of text's property. Returns ------- bool If text is italic. """ return bool(self.GetItalic()) @italic.setter def italic(self, italic: bool): self.SetItalic(italic) @property def bold(self) -> bool: """Bold of text's property. Returns ------- bool If text is bold. """ return bool(self.GetBold()) @bold.setter def bold(self, bold: bool): self.SetBold(bold) def shallow_copy(self, to_copy: TextProperty) -> None: """Create a shallow copy of the text's property. Parameters ---------- to_copy : pyvista.TextProperty Text's property to copy from. """ self.ShallowCopy(to_copy)