"""Matplotlib rendering for :class:`irsim.world.object_base.ObjectBase`."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field, replace
from math import cos, pi, sin
from typing import TYPE_CHECKING, Any
import matplotlib.transforms as mtransforms
import numpy as np
from matplotlib import image
from matplotlib.patches import Arrow, Circle, Wedge
from mpl_toolkits.mplot3d import Axes3D
from irsim.config import palette_param
from irsim.config.path_param import path_manager
from irsim.env.env_plot import (
draw_patch,
linewidth_from_data_units,
set_patch_property,
)
from irsim.util.util import file_check
if TYPE_CHECKING:
from irsim.world.object_base import ObjectBase
[docs]
@dataclass(frozen=True, slots=True)
class VisibilityOptions:
"""Control which components of an object are rendered."""
goal: bool = False # Draw the goal geometry.
goal_text: bool = False # Draw the goal label with object text.
goals: bool = False # Preserve the legacy multi-goal flag.
text: bool = False # Draw the object label.
arrow: bool = False # Draw the velocity-direction arrow.
trajectory: bool = False # Draw the continuous trajectory line.
trail: bool = False # Draw historical shape snapshots.
sensor: bool = True # Draw attached sensor overlays.
fov: bool = False # Draw the field-of-view region.
[docs]
@dataclass(frozen=True, slots=True)
class PatchStyle:
"""Style shared by object and goal patches."""
color: Any = "k" # Matplotlib-compatible color.
alpha: float = 1.0 # Opacity from fully transparent to opaque.
zorder: int = 1 # Layer order; larger values render above.
linestyle: str = "-" # Patch boundary style.
line_width: float = 1.0 # Boundary width in Matplotlib points.
[docs]
@dataclass(frozen=True, slots=True)
class ImageStyle:
"""Style specific to an object's description image."""
zorder: int = 2 # Preserve the legacy image layer default.
[docs]
@dataclass(frozen=True, slots=True)
class LineStyle:
"""Style and history length for a trajectory line."""
color: Any = "k" # Matplotlib-compatible line color.
style: str = "-" # Line style, such as "-" or "--".
width: float = 1.0 # Width in environment data units.
alpha: float = 0.5 # Line opacity.
zorder: int = 0 # Matplotlib layer order.
keep_length: int = 0 # Recent states retained; 0 keeps all.
[docs]
@dataclass(frozen=True, slots=True)
class TextStyle:
"""Style and offset for object and goal labels."""
color: Any = "k" # Matplotlib-compatible text color.
size: float = 10.0 # Font size in points.
position: tuple[float, float] = (0.0, 0.0) # Label offset.
alpha: float = 1.0 # Text opacity.
zorder: int = 2 # Matplotlib layer order.
[docs]
@dataclass(frozen=True, slots=True)
class TrailStyle:
"""Style, frequency, and retention policy for object trails."""
shape: str = "circle" # Geometry used for each snapshot.
edge_color: Any = "k" # Matplotlib-compatible boundary color.
line_width: float = 0.8 # Boundary width passed to Matplotlib.
alpha: float = 0.7 # Snapshot opacity.
fill: bool = False # Fill the snapshot interior when true.
color: Any = "k" # Matplotlib-compatible interior color.
zorder: int = 0 # Matplotlib layer order.
frequency: int = 2 # Simulation steps between snapshots.
keep_length: int = 0 # Maximum snapshots; 0 keeps all.
[docs]
@dataclass(frozen=True, slots=True)
class ArrowStyle:
"""Style and dimensions for the velocity arrow."""
color: Any = field(default_factory=lambda: palette_param.arrow) # arrow color
alpha: float = 1.0 # Arrow opacity.
zorder: int = 3 # Matplotlib layer order.
length: float = 0.4 # Length in environment data units.
width: float = 0.6 # Arrow-head width in data units.
[docs]
@dataclass(frozen=True, slots=True)
class FovStyle:
"""Style for the field-of-view patch."""
color: Any = field(default_factory=lambda: palette_param.fov) # interior
edge_color: Any = field(default_factory=lambda: palette_param.fov_edge) # boundary
alpha: float = 0.5 # Region opacity.
zorder: int = 1 # Matplotlib layer order.
[docs]
@dataclass(frozen=True, slots=True)
class ObjectPlotOptions:
"""Complete immutable plotting configuration for one object."""
visibility: VisibilityOptions = field(default_factory=VisibilityOptions)
object: PatchStyle = field(default_factory=PatchStyle)
image: ImageStyle = field(default_factory=ImageStyle)
goal: PatchStyle = field(default_factory=lambda: PatchStyle(alpha=0.5, zorder=1))
trajectory: LineStyle = field(default_factory=LineStyle)
text: TextStyle = field(default_factory=TextStyle)
trail: TrailStyle = field(default_factory=TrailStyle)
arrow: ArrowStyle = field(default_factory=ArrowStyle)
fov: FovStyle = field(default_factory=FovStyle)
[docs]
@classmethod
def defaults(
cls,
*,
color: Any,
width: float,
radius: float,
shape: str,
object_zorder: int,
) -> ObjectPlotOptions:
"""Create owner-dependent plotting defaults."""
return cls(
object=PatchStyle(color=color, zorder=object_zorder),
goal=PatchStyle(color=color, alpha=0.5, zorder=1),
trajectory=LineStyle(color=color, width=width),
text=TextStyle(position=(-radius - 0.1, radius + 0.1)),
trail=TrailStyle(
shape=shape,
edge_color=color,
color=color,
),
)
[docs]
def with_overrides(self, values: Mapping[str, Any]) -> ObjectPlotOptions:
"""Return a new options value with legacy keyword overrides applied."""
visibility = _replace_fields(
self.visibility,
values,
{
"show_goal": "goal",
"show_goal_text": "goal_text",
"show_goals": "goals",
"show_text": "text",
"show_arrow": "arrow",
"show_trajectory": "trajectory",
"show_trail": "trail",
"show_sensor": "sensor",
"show_fov": "fov",
},
)
object_style = _replace_fields(
self.object,
values,
{
"obj_color": "color",
"obj_alpha": "alpha",
"obj_zorder": "zorder",
"obj_linestyle": "linestyle",
"obj_linewidth": "line_width",
},
)
image = _replace_fields(
self.image,
values,
{"obj_zorder": "zorder"},
)
goal = _replace_fields(
self.goal,
values,
{
"goal_color": "color",
"goal_alpha": "alpha",
"goal_zorder": "zorder",
},
)
trajectory = _replace_fields(
self.trajectory,
values,
{
"traj_color": "color",
"traj_style": "style",
"traj_width": "width",
"traj_alpha": "alpha",
"traj_zorder": "zorder",
"keep_traj_length": "keep_length",
},
)
text = _replace_fields(
self.text,
values,
{
"text_color": "color",
"text_size": "size",
"text_alpha": "alpha",
"text_zorder": "zorder",
},
)
if "text_position" in values:
text = replace(text, position=tuple(values["text_position"]))
trail = _replace_fields(
self.trail,
values,
{
"trail_type": "shape",
"trail_edgecolor": "edge_color",
"trail_linewidth": "line_width",
"trail_alpha": "alpha",
"trail_fill": "fill",
"trail_color": "color",
"trail_zorder": "zorder",
"trail_freq": "frequency",
"keep_trail_length": "keep_length",
},
)
arrow = _replace_fields(
self.arrow,
values,
{
"arrow_color": "color",
"arrow_alpha": "alpha",
"arrow_zorder": "zorder",
"arrow_length": "length",
"arrow_width": "width",
},
)
fov = _replace_fields(
self.fov,
values,
{
"fov_color": "color",
"fov_edge_color": "edge_color",
"fov_alpha": "alpha",
"fov_zorder": "zorder",
},
)
return replace(
self,
visibility=visibility,
object=object_style,
image=image,
goal=goal,
trajectory=trajectory,
text=text,
trail=trail,
arrow=arrow,
fov=fov,
)
def _replace_fields(
value: Any,
options: Mapping[str, Any],
field_names: Mapping[str, str],
) -> Any:
"""Replace dataclass fields whose legacy option keys are present."""
changes = {
field_name: options[option_name]
for option_name, field_name in field_names.items()
if option_name in options
}
return replace(value, **changes) if changes else value
_PLOT_ATTRIBUTES = (
"object_patch",
"object_line",
"object_img",
"goal_patch",
"_text",
"_goal_text",
"arrow_patch",
"trajectory_line",
"fov_patch",
)
[docs]
class ObjectPlot:
"""Render and update the Matplotlib artists for one simulation object.
Simulation and artist state is accessed explicitly through ``owner``.
Artist attributes intentionally remain on ``ObjectBase`` to preserve the
existing plotting API while moving rendering behavior out of the model.
"""
def __init__(self, owner: ObjectBase) -> None:
self._owner: ObjectBase = owner
self.options = self._resolve_options()
@property
def owner(self) -> ObjectBase:
"""Return the owner through the explicit renderer interface."""
return self._owner
@owner.setter
def owner(self, value: ObjectBase) -> None:
"""Keep explicit owner assignment synchronized with legacy ``_owner``."""
self._owner = value
def __getattr__(self, name: str) -> Any:
"""Delegate legacy renderer attribute reads to the owning object."""
owner = object.__getattribute__(self, "_owner")
return getattr(owner, name)
def _resolve_options(
self, overrides: Mapping[str, Any] | None = None
) -> ObjectPlotOptions:
"""Resolve live owner defaults, stored configuration, and call overrides."""
owner = self.owner
options = ObjectPlotOptions.defaults(
color=owner.color,
width=owner.width,
radius=owner.radius,
shape=owner.shape,
object_zorder=3 if owner.role == "robot" else 1,
).with_overrides(owner.plot_kwargs)
return options.with_overrides(overrides) if overrides else options
[docs]
def plot(
self,
ax: Any,
state: np.ndarray | None = None,
vertices: np.ndarray | None = None,
**kwargs: Any,
) -> None:
"""Plot the object on ``ax`` using optional state and vertices."""
state = self.owner.state if state is None else state
vertices = self.owner.vertices if vertices is None else vertices
self.draw(ax, state, vertices, **kwargs)
[docs]
def init(self, ax: Any, **kwargs: Any) -> list[str]:
"""Create the object's artists at its original state."""
if (
not self.owner.static
and "show_arrow" not in self.owner.plot_kwargs
and "show_arrow" not in kwargs
):
kwargs.setdefault("show_arrow", self.owner.kf.show_arrow)
if self.owner.shape == "compound":
return self.draw(
ax,
self.owner.state,
None,
initial=True,
**kwargs,
)
return self.draw(
ax,
self.owner.original_state,
self.owner.original_vertices,
initial=True,
**kwargs,
)
[docs]
def draw(
self,
ax: Any,
state: np.ndarray,
vertices: np.ndarray | None,
initial: bool = False,
**kwargs: Any,
) -> list[str]:
"""Create the configured artists for one object."""
owner = self.owner
owner.plot_kwargs.update(kwargs)
options = self._resolve_options()
self.options = options
owner.plot_attr_list = list(_PLOT_ATTRIBUTES)
owner.ax = ax
visibility = options.visibility
owner.show_goal = visibility.goal
owner.show_goal_text = visibility.goal_text
owner.show_goals = visibility.goals
owner.show_trail = visibility.trail
owner.show_sensor = visibility.sensor
owner.trail_freq = options.trail.frequency
if owner.shape != "map":
self.plot_object(ax, state, vertices, _options=options)
if visibility.goal:
goal_state = state if initial else owner.goal
goal_vertices = vertices if initial else owner.goal_vertices
self.plot_goal(ax, goal_state, goal_vertices, _options=options)
if visibility.text:
self.plot_text(ax, state, _options=options)
if visibility.arrow:
current_velocity = owner.velocity_xy if np.any(state) else np.zeros((2, 1))
arrow_theta = 0.0 if initial else owner.heading
self.plot_arrow(
ax,
state,
current_velocity,
arrow_theta,
_options=options,
)
if visibility.trajectory:
trajectory_data = owner.trajectory if np.any(state) else []
self.plot_trajectory(ax, trajectory_data, _options=options)
if (
visibility.trail
and owner._world_param.count % options.trail.frequency == 0
and owner._world_param.count > 0
):
self.plot_trail(ax, state, owner.vertices, _options=options)
if visibility.sensor:
sensor_options = {**owner.plot_kwargs, **kwargs}
for sensor in owner.sensors:
sensor.plot(ax, state, **sensor_options)
if visibility.fov:
self.plot_fov(ax, _options=options)
return owner.plot_attr_list
[docs]
def step(self, **kwargs: Any) -> None:
"""Update existing artists from the owner's current state."""
owner = self.owner
options = self._resolve_options(kwargs)
self.options = options
state = owner.state
x = float(state[0, 0])
y = float(state[1, 0])
heading = float(state[2, 0])
self._update_object_patch(options.object)
self._update_object_line(x, y, heading, options.object)
self._update_object_image(heading)
self._update_goal_patch(options.goal)
self._update_arrow_patch(x, y, options.arrow)
self._update_trajectory(options.trajectory)
self._update_fov_patch(x, y, heading, options.fov)
self._update_text(x, y, options.text)
self._update_goal_text(options.text)
if (
options.visibility.trail
and owner._world_param.count % options.trail.frequency == 0
):
self.plot_trail(owner.ax, state, owner.original_vertices, _options=options)
if options.visibility.sensor:
for sensor in owner.sensors:
sensor.step_plot()
def _artist(self, name: str) -> Any | None:
"""Return an artist stored on the owner, if it has been created."""
return getattr(self.owner, name, None)
def _update_object_patch(self, style: PatchStyle) -> None:
element = self._artist("object_patch")
if element is None:
return
set_patch_property(
element,
self.owner.ax,
state=self.owner.state,
color=style.color,
alpha=style.alpha,
zorder=style.zorder,
linestyle=style.linestyle,
linewidth=style.line_width,
)
def _update_object_line(
self,
x: float,
y: float,
heading: float,
style: PatchStyle,
) -> None:
element = self._artist("object_line")
if element is None:
return
cos_heading = np.cos(heading)
sin_heading = np.sin(heading)
rotation = np.array([[cos_heading, -sin_heading], [sin_heading, cos_heading]])
vertices = rotation @ self.owner.vertices + np.array([[x], [y]])
element.set_data(vertices[0, :], vertices[1, :])
element.set_linestyle(style.linestyle)
element.set_color(style.color)
element.set_alpha(style.alpha)
element.set_zorder(style.zorder)
element.set_linewidth(style.line_width)
def _update_object_image(self, heading: float) -> None:
element = self._artist("object_img")
if element is None or isinstance(self.owner.ax, Axes3D):
return
start_x = float(self.owner.vertices[0, 0])
start_y = float(self.owner.vertices[1, 0])
element.set_extent(
[
start_x,
start_x + self.owner.length,
start_y,
start_y + self.owner.width,
]
)
transform = (
mtransforms.Affine2D().rotate_around(start_x, start_y, heading)
+ self.owner.ax.transData
)
element.set_transform(transform)
def _update_goal_patch(self, style: PatchStyle) -> None:
element = self._artist("goal_patch")
if element is None:
return
if self.owner.goal is None:
element.set_visible(False)
return
element.set_visible(True)
goal_state = (
self.owner.goal
if self.owner.goal.shape[0] > 2
else np.pad(
self.owner.goal, ((0, 1), (0, 0)), "constant", constant_values=0
)
)
set_patch_property(
element,
self.owner.ax,
state=goal_state,
color=style.color,
alpha=style.alpha,
zorder=style.zorder,
)
def _update_arrow_patch(
self,
x: float,
y: float,
style: ArrowStyle,
) -> None:
element = self._artist("arrow_patch")
if not isinstance(element, Arrow):
return
arrow_state = np.array([[x], [y], [self.owner.heading]])
set_patch_property(
element,
self.owner.ax,
state=arrow_state,
color=style.color,
alpha=style.alpha,
zorder=style.zorder,
)
def _update_fov_patch(
self,
x: float,
y: float,
heading: float,
style: FovStyle,
) -> None:
element = self._artist("fov_patch")
if not isinstance(element, Wedge | Circle):
return
direction = heading if self.owner.state_dim >= 3 else 0.0
set_patch_property(
element,
self.owner.ax,
state=np.array([[x], [y], [direction]]),
facecolor=style.color,
edgecolor=style.edge_color,
alpha=style.alpha,
zorder=style.zorder,
)
def _update_trajectory(self, style: LineStyle) -> None:
"""Update a trajectory line and its runtime style properties."""
element = self._artist("trajectory_line")
if not isinstance(element, list) or not element:
return
line = element[0]
trajectory = self.owner.trajectory[-style.keep_length :]
x_list, y_list = self._trajectory_coordinates(trajectory)
if isinstance(self.owner.ax, Axes3D):
line.set_data_3d(x_list, y_list, [0] * len(x_list))
else:
line.set_data(x_list, y_list)
ax = line.axes
if ax is not None:
line.set_linewidth(linewidth_from_data_units(style.width, ax, "y"))
line.set_color(style.color)
line.set_linestyle(style.style)
line.set_alpha(style.alpha)
line.set_zorder(style.zorder)
def _trajectory_coordinates(
self, trajectory: list[Any]
) -> tuple[list[float], list[float]]:
if self.owner.shape == "compound":
_, min_y, _, max_y = self.owner.original_geometry.bounds
center_y = (min_y + max_y) / 2
return (
[
float(state[0, 0] - sin(float(state[2, 0])) * center_y)
for state in trajectory
],
[
float(state[1, 0] + cos(float(state[2, 0])) * center_y)
for state in trajectory
],
)
return (
[float(state[0, 0]) for state in trajectory],
[float(state[1, 0]) for state in trajectory],
)
def _update_text(self, x: float, y: float, style: TextStyle) -> None:
"""Update the object label when it exists."""
owner = self.owner
if not hasattr(owner, "_text"):
return
text = owner._text
text.set_position((x + style.position[0], y + style.position[1]))
text.set_text(owner._get_text())
self._set_text_properties(text, style)
def _update_goal_text(self, style: TextStyle) -> None:
"""Update the goal label when the object has a goal."""
owner = self.owner
if owner.goal is None or not hasattr(owner, "_goal_text"):
return
goal_text = owner._goal_text
goal_text.set_position(
(
owner.goal[0, 0] + style.position[0],
owner.goal[1, 0] + style.position[1],
)
)
goal_text.set_text(owner._get_goal_text())
self._set_text_properties(goal_text, style)
@staticmethod
def _set_text_properties(text: Any, style: TextStyle) -> None:
"""Apply resolved properties to a Matplotlib text artist."""
text.set_color(style.color)
text.set_fontsize(style.size)
text.set_alpha(style.alpha)
text.set_zorder(style.zorder)
[docs]
def plot_object(
self,
ax: Any,
state: np.ndarray | None = None,
vertices: np.ndarray | None = None,
_options: ObjectPlotOptions | None = None,
**kwargs: Any,
) -> None:
"""Draw the object's geometry or configured description image."""
options = self._resolve_options(kwargs) if _options is None else _options
state = self.owner.state if state is None else state
vertices = self.owner.vertices if vertices is None else vertices
if (
self.owner.shape == "compound"
or self.owner.description is None
or isinstance(ax, Axes3D)
):
try:
if self.owner.shape != "map":
self.owner.object_patch = draw_patch(
ax,
shape=self.owner.shape,
state=state,
radius=self.owner.radius,
center=(
self.owner.original_centroid
if self.owner.shape == "circle"
else None
),
vertices=vertices,
geometry=(
self.owner.original_geometry
if self.owner.shape == "compound"
else None
),
color=options.object.color,
alpha=options.object.alpha,
linestyle=options.object.linestyle,
zorder=options.object.zorder,
)
self.owner.object_patch.set_linewidth(options.object.line_width)
self.owner.plot_patch_list.append(self.owner.object_patch)
except Exception as exc:
self.owner.logger.error(
f"Error occurred while plotting object: {exc!s}"
)
raise
else:
self.plot_object_image(
ax,
state,
vertices,
self.owner.description,
**kwargs,
)
[docs]
def plot_object_image(
self,
ax: Any,
state: np.ndarray | None = None,
vertices: np.ndarray | None = None,
description: str | None = None,
**kwargs: Any,
) -> None:
"""Draw an image description at the object's pose."""
if vertices is None or state is None:
return
image_path = file_check(
description,
root_path=path_manager.root_path + "/world/description/",
)
if image_path is None:
return
start_x = float(vertices[0, 0])
start_y = float(vertices[1, 0])
angle_degrees = 180 * float(state[2, 0]) / pi
image_data = image.imread(image_path)
options = self._resolve_options(kwargs)
object_image = ax.imshow(
image_data,
extent=[
start_x,
start_x + self.owner.length,
start_y,
start_y + self.owner.width,
],
zorder=options.image.zorder,
)
transform = (
mtransforms.Affine2D().rotate_deg_around(start_x, start_y, angle_degrees)
+ ax.transData
)
object_image.set_transform(transform)
self.owner.plot_patch_list.append(object_image)
self.owner.object_img = object_image
[docs]
def plot_trajectory(
self,
ax: Any,
trajectory: list[Any] | None = None,
keep_traj_length: int = 0,
_options: ObjectPlotOptions | None = None,
**kwargs: Any,
) -> None:
"""Draw the object's trajectory."""
if _options is None:
options = self._resolve_options(
{**kwargs, "keep_traj_length": keep_traj_length}
)
else:
options = _options
trajectory = self.owner.trajectory if trajectory is None else trajectory
self.owner.keep_traj_length = options.trajectory.keep_length
kept_trajectory = trajectory[-options.trajectory.keep_length :]
x_list, y_list = self._trajectory_coordinates(kept_trajectory)
linewidth = linewidth_from_data_units(options.trajectory.width, ax, "y")
if isinstance(ax, Axes3D):
linewidth = options.trajectory.width * 10
solid_capstyle = "round" if self.owner.shape == "circle" else "butt"
self.owner.trajectory_line = ax.plot(
x_list,
y_list,
color=options.trajectory.color,
linestyle=options.trajectory.style,
linewidth=linewidth,
solid_joinstyle="round",
solid_capstyle=solid_capstyle,
alpha=options.trajectory.alpha,
zorder=options.trajectory.zorder,
)
self.owner.plot_line_list.append(self.owner.trajectory_line)
[docs]
def plot_goal(
self,
ax: Any,
goal_state: np.ndarray | None = None,
vertices: np.ndarray | None = None,
goal_color: str | None = None,
goal_zorder: int | None = 1,
goal_alpha: float | None = 0.5,
_options: ObjectPlotOptions | None = None,
**kwargs: Any,
) -> None:
"""Draw the object's goal geometry."""
if goal_state is None:
return
if _options is None:
overrides = {
**kwargs,
"goal_zorder": goal_zorder,
"goal_alpha": goal_alpha,
}
if goal_color is not None:
overrides["goal_color"] = goal_color
options = self._resolve_options(overrides)
else:
options = _options
self.owner.goal_patch = draw_patch(
ax,
shape=self.owner.shape,
state=goal_state,
radius=self.owner.radius,
vertices=vertices,
geometry=(
self.owner.original_geometry if self.owner.shape == "compound" else None
),
color=options.goal.color,
alpha=options.goal.alpha,
zorder=options.goal.zorder,
)
self.owner.plot_patch_list.append(self.owner.goal_patch)
[docs]
def plot_text(
self,
ax: Any,
state: np.ndarray | None = None,
_options: ObjectPlotOptions | None = None,
**kwargs: Any,
) -> None:
"""Draw the object label and optional goal label."""
options = self._resolve_options(kwargs) if _options is None else _options
state = self.owner.state if state is None else state
x, y = state[0, 0], state[1, 0]
if isinstance(ax, Axes3D):
self.owner._text = ax.text(
x + options.text.position[0],
y + options.text.position[1],
self.owner.z,
self.owner._get_text(),
fontsize=options.text.size,
color=options.text.color,
zorder=options.text.zorder,
alpha=options.text.alpha,
)
else:
self.owner._text = ax.text(
x + options.text.position[0],
y + options.text.position[1],
self.owner._get_text(),
fontsize=options.text.size,
color=options.text.color,
zorder=options.text.zorder,
alpha=options.text.alpha,
)
self.owner.plot_text_list.append(self.owner._text)
if (
getattr(self.owner, "show_goal", options.visibility.goal)
and getattr(self.owner, "show_goal_text", options.visibility.goal_text)
and self.owner.goal is not None
):
self._plot_goal_text(ax, options.text)
def _plot_goal_text(
self,
ax: Any,
style: TextStyle,
) -> None:
"""Create the goal label for ``plot_text``."""
goal_x, goal_y = self.owner.goal[0, 0], self.owner.goal[1, 0]
args = [
goal_x + style.position[0],
goal_y + style.position[1],
]
if isinstance(ax, Axes3D):
args.append(self.owner.z)
self.owner._goal_text = ax.text(
*args,
self.owner._get_goal_text(),
fontsize=style.size,
color=style.color,
zorder=style.zorder,
alpha=style.alpha,
)
self.owner.plot_text_list.append(self.owner._goal_text)
[docs]
def plot_arrow(
self,
ax: Any,
state: np.ndarray | None = None,
velocity: np.ndarray | None = None,
arrow_theta: float | None = 0.0,
arrow_length: float = 0.4,
arrow_width: float = 0.6,
arrow_color: str | None = None,
arrow_zorder: int = 3,
_options: ObjectPlotOptions | None = None,
**kwargs: Any,
) -> None:
"""Draw an arrow indicating the object's velocity orientation."""
if _options is None:
overrides = {
**kwargs,
"arrow_length": arrow_length,
"arrow_width": arrow_width,
"arrow_zorder": arrow_zorder,
}
if arrow_color is not None:
overrides["arrow_color"] = arrow_color
options = self._resolve_options(overrides)
else:
options = _options
state = self.owner.state if state is None else state
self.owner.arrow_patch = draw_patch(
ax,
shape="arrow",
state=state,
color=options.arrow.color,
alpha=options.arrow.alpha,
zorder=options.arrow.zorder,
arrow_length=options.arrow.length,
arrow_width=options.arrow.width,
theta=arrow_theta,
)
self.owner.plot_patch_list.append(self.owner.arrow_patch)
[docs]
def plot_trail(
self,
ax: Any,
state: np.ndarray | None = None,
vertices: np.ndarray | None = None,
keep_trail_length: int = 0,
_options: ObjectPlotOptions | None = None,
**kwargs: Any,
) -> None:
"""Draw a historical outline of the object."""
if _options is None:
options = self._resolve_options(
{**kwargs, "keep_trail_length": keep_trail_length}
)
else:
options = _options
vertices = self.owner.original_vertices if vertices is None else vertices
trail = draw_patch(
ax,
shape=options.trail.shape,
state=state,
vertices=vertices,
geometry=(
self.owner.original_geometry
if options.trail.shape == "compound"
else None
),
radius=self.owner.radius,
center=(
self.owner.original_centroid
if options.trail.shape == "circle"
else None
),
width=self.owner.length,
height=self.owner.width,
edgecolor=options.trail.edge_color,
facecolor=options.trail.color,
fill=options.trail.fill,
alpha=options.trail.alpha,
linewidth=options.trail.line_width,
zorder=options.trail.zorder,
)
self.owner.plot_trail_list.append(trail)
if (
len(self.owner.plot_trail_list) > options.trail.keep_length
and options.trail.keep_length > 0
):
self.owner.plot_trail_list.pop(0).remove()
[docs]
def plot_fov(
self,
ax: Any,
_options: ObjectPlotOptions | None = None,
**kwargs: Any,
) -> None:
"""Draw the object's configured field of view."""
if self.owner.fov is None or self.owner.fov_radius is None:
return
options = self._resolve_options(kwargs) if _options is None else _options
start_degree = -180 * self.owner.fov / (2 * pi)
end_degree = 180 * self.owner.fov / (2 * pi)
shape = "circle" if abs(self.owner.fov - 2 * pi) < 0.01 else "wedge"
self.owner.fov_patch = draw_patch(
ax,
shape=shape,
state=np.zeros((3, 1)),
radius=self.owner.fov_radius,
theta1=start_degree,
theta2=end_degree,
facecolor=options.fov.color,
edgecolor=options.fov.edge_color,
alpha=options.fov.alpha,
zorder=options.fov.zorder,
)
self.owner.plot_patch_list.append(self.owner.fov_patch)
[docs]
def plot_uncertainty(self, ax: Any, **kwargs: Any) -> None:
"""Reserved for future uncertainty rendering."""
[docs]
def clear(self, all: bool = False) -> None:
"""Remove this object's artists, optionally including its trails."""
for patch in self.owner.plot_patch_list:
patch.remove()
for line in self.owner.plot_line_list:
line.pop(0).remove()
for text in self.owner.plot_text_list:
text.remove()
if all:
for trail in self.owner.plot_trail_list:
trail.remove()
self.owner.plot_trail_list = []
self.owner.plot_patch_list = []
self.owner.plot_line_list = []
self.owner.plot_text_list = []
for sensor in self.owner.sensors:
sensor.plot_clear()