diff --git a/CHANGELOG.md b/CHANGELOG.md index 49913dfbb..c6eefb8f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Attention: The newest changes should be on top --> ### Added +- ENH: Add 3D flight trajectory and attitude animations in Flight plots layer [#909](https://github.com/RocketPy-Team/RocketPy/pull/909) - ENH: Auto Populate Changelog [#919](https://github.com/RocketPy-Team/RocketPy/pull/919) - ENH: Adaptive Monte Carlo via Convergence Criteria [#922](https://github.com/RocketPy-Team/RocketPy/pull/922) - TST: Add acceptance tests for 3DOF flight simulation based on Bella Lui rocket [#914](https://github.com/RocketPy-Team/RocketPy/pull/914) diff --git a/docs/notebooks/getting_started_colab.ipynb b/docs/notebooks/getting_started_colab.ipynb index ef0f7e711..154a8cd4c 100644 --- a/docs/notebooks/getting_started_colab.ipynb +++ b/docs/notebooks/getting_started_colab.ipynb @@ -596,6 +596,18 @@ ")" ] }, + { + "cell_type": "markdown", + "source": "## 3D Flight Animation\n\nRocketPy can render an interactive 3D animation of the rocket's trajectory and attitude using [vedo](https://vedo.embl.es/). This feature requires the optional `animation` extra:\n\n```bash\npip install rocketpy[animation]\n```\n\n> **Note:** The interactive animation window opens in a desktop environment. It will not display inside Google Colab or other headless notebook servers — run it locally for the best experience.\n\nTwo animation modes are available:\n\n| Method | What it shows |\n|---|---|\n| `flight.plots.animate_trajectory()` | Rocket moves through 3D space following the simulated trajectory |\n| `flight.plots.animate_rotate()` | Rocket stays centred; only attitude (rotation) is animated |\n\nBoth methods accept an optional `file_name` argument pointing to a custom `.stl` model. When omitted, RocketPy uses a built-in default rocket shape.", + "metadata": {} + }, + { + "cell_type": "code", + "source": "# Install the optional animation dependency (skip if already installed)\n!pip install \"rocketpy[animation]\"\n\n# Animate the full trajectory — rocket moves through 3D space\n# Press Escape or close the window to exit the animation\ntest_flight.plots.animate_trajectory(\n start=0,\n stop=test_flight.t_final,\n time_step=0.05,\n)\n\n# Alternatively, animate only the attitude changes (rocket stays centred)\n# test_flight.plots.animate_rotate(\n# start=0,\n# stop=test_flight.t_final,\n# time_step=0.05,\n# )\n\n# To use your own 3D model, pass its path via file_name:\n# test_flight.plots.animate_trajectory(file_name=\"my_rocket.stl\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, { "attachments": {}, "cell_type": "markdown", diff --git a/docs/user/flight.rst b/docs/user/flight.rst index 31e7ab588..a220554ab 100644 --- a/docs/user/flight.rst +++ b/docs/user/flight.rst @@ -274,7 +274,7 @@ During the rail launch phase, RocketPy calculates reaction forces and internal b **Rail Button Forces (N):** - ``rail_button1_normal_force`` : Normal reaction force at upper rail button -- ``rail_button1_shear_force`` : Shear (tangential) reaction force at upper rail button +- ``rail_button1_shear_force`` : Shear (tangential) reaction force at upper rail button - ``rail_button2_normal_force`` : Normal reaction force at lower rail button - ``rail_button2_shear_force`` : Shear (tangential) reaction force at lower rail button @@ -282,7 +282,7 @@ During the rail launch phase, RocketPy calculates reaction forces and internal b - ``rail_button1_bending_moment`` : Time-dependent bending moment at upper rail button attachment - ``max_rail_button1_bending_moment`` : Maximum absolute bending moment at upper rail button -- ``rail_button2_bending_moment`` : Time-dependent bending moment at lower rail button attachment +- ``rail_button2_bending_moment`` : Time-dependent bending moment at lower rail button attachment - ``max_rail_button2_bending_moment`` : Maximum absolute bending moment at lower rail button **Calculation Method:** @@ -454,6 +454,122 @@ Flight Data Plots # Flight path and orientation flight.plots.flight_path_angle_data() +3D Flight Animation +~~~~~~~~~~~~~~~~~~~ + +RocketPy can produce real-time interactive 3D animations of the simulated +flight using `vedo `_, a scientific visualization +library built on top of VTK. Two complementary animation modes are provided: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Method + - What it shows + * - ``flight.plots.animate_trajectory()`` + - The rocket 3D model moves through space following the simulated + trajectory; a black trail line is drawn behind it. + * - ``flight.plots.animate_rotate()`` + - The rocket 3D model stays centred in the scene; only its attitude + (orientation derived from the quaternion solution) is animated. + +.. note:: + + The animation window opens on the desktop via VTK. It will **not** render + inside headless environments such as Google Colab. For notebook use, run + the cell on a local Jupyter server or JupyterLab installation. + +**Installation** + +The ``vedo`` dependency is not installed by default. Add the optional extra +before calling either animation method: + +.. code-block:: bash + + pip install rocketpy[animation] + +If ``vedo`` is not available when an animation method is called, RocketPy +raises an :class:`ImportError` with the above install command embedded in the +message. + +**animate_trajectory — full 6-DOF trajectory animation** + +.. code-block:: python + + # Quickstart: uses RocketPy's built-in default STL rocket model + flight.plots.animate_trajectory() + + # Customise the time window and frame rate + flight.plots.animate_trajectory( + start=0.0, # start time in seconds (default: 0) + stop=flight.t_final, # end time in seconds (default: t_final) + time_step=0.05, # seconds per frame (default: 0.1) + ) + + # Provide your own 3D model (any STL file) + flight.plots.animate_trajectory( + file_name="my_rocket.stl", + start=0.0, + stop=flight.t_final, + time_step=0.05, + ) + +**animate_rotate — attitude-only animation** + +Useful for inspecting roll, pitch, and yaw behaviour without the distraction of +the trajectory translation. The rocket mesh is fixed at its position at +``start`` and only rotated according to the quaternion solution. + +.. code-block:: python + + flight.plots.animate_rotate( + start=0.0, + stop=flight.t_final, + time_step=0.05, + ) + +**Parameters** + +Both methods share the same signature: + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Parameter + - Default + - Description + * - ``file_name`` + - ``None`` + - Path to a ``.stl`` model file. When ``None``, the built-in default + rocket shape packaged with RocketPy is used. + * - ``start`` + - ``0`` + - Animation start time in seconds. Must be within + ``[0, flight.t_final]``. + * - ``stop`` + - ``None`` + - Animation end time in seconds. ``None`` defaults to + ``flight.t_final``. + * - ``time_step`` + - ``0.1`` + - Duration of each frame in seconds. Smaller values produce smoother + animations at the cost of longer render times. Must be > 0. + * - ``**kwargs`` + - — + - Additional keyword arguments forwarded to ``vedo.Plotter.show`` + (e.g. ``viewup``, ``azimuth``, ``elevation``). + +**Tips** + +- A ``time_step`` of ``0.05`` (20 fps) is a good balance between smoothness + and performance for flights lasting tens of seconds. +- Press **Escape** or close the vedo window to exit the animation loop early. +- Both methods validate ``start``, ``stop``, ``time_step``, and the STL path + before any rendering begins, raising a :class:`ValueError` with a descriptive + message on invalid input. + Forces and Moments ~~~~~~~~~~~~~~~~~~ diff --git a/docs/user/installation.rst b/docs/user/installation.rst index 72ba1f42d..a08c65aeb 100644 --- a/docs/user/installation.rst +++ b/docs/user/installation.rst @@ -138,22 +138,40 @@ To update Scipy and install netCDF4 using Conda, the following code is used: Optional Packages ^^^^^^^^^^^^^^^^^ -The EnvironmentAnalysis class requires a few extra packages to be installed. -In case you want to use this class, you will need to install the following packages: +RocketPy has several optional feature sets that can be installed individually. -- `timezonefinder` : to allow for automatic timezone detection, -- `windrose` : to allow for windrose plots, -- `ipywidgets` : to allow for GIFs generation, -- `jsonpickle` : to allow for saving and loading of class instances. +**Environment Analysis** — extra plots and tools for the +:class:`rocketpy.EnvironmentAnalysis` class: -You can install all these packages by simply running the following lines in your preferred terminal: +- `timezonefinder` : automatic timezone detection +- `windrose` : windrose plots +- `ipywidgets` : GIF generation +- `jsonpickle` : saving and loading class instances .. code-block:: shell pip install rocketpy[env_analysis] +**3D Flight Animation** — interactive 3D animations of rocket trajectory and +attitude using `vedo `_ (requires a desktop environment): -Alternatively, you can instal all extra packages by running the following line in your preferred terminal: +.. code-block:: shell + + pip install rocketpy[animation] + +Once installed, you can render animations from a :class:`rocketpy.Flight` object: + +.. code-block:: python + + # Animate rocket moving through 3D space + flight.plots.animate_trajectory(start=0, stop=flight.t_final, time_step=0.05) + + # Animate attitude changes only (rocket stays centred) + flight.plots.animate_rotate(start=0, stop=flight.t_final, time_step=0.05) + +See :ref:`flightusage` for full details and parameter descriptions. + +**All extras** — install every optional dependency at once: .. code-block:: shell diff --git a/pyproject.toml b/pyproject.toml index 4f1ecced4..09ce86273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = { find = { where = ["."], include = ["rocketpy*"] } } +[tool.setuptools.package-data] +"rocketpy.plots" = ["assets/*.stl"] + [tool.setuptools.dynamic] dependencies = { file = ["requirements.txt"] } @@ -61,14 +64,18 @@ env-analysis = [ ] monte-carlo = [ - "imageio", + "imageio", "multiprocess>=0.70", "statsmodels", "prettytable", "contextily>=1.0.0; python_version < '3.14'", ] -all = ["rocketpy[env-analysis]", "rocketpy[monte-carlo]"] +animation = [ + "vedo>=2024.5.1" +] + +all = ["rocketpy[env-analysis]", "rocketpy[monte-carlo]", "rocketpy[animation]"] [tool.coverage.report] diff --git a/requirements-optional.txt b/requirements-optional.txt index 58ed1030b..2961946ca 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -6,4 +6,5 @@ timezonefinder imageio multiprocess>=0.70 statsmodels -prettytable \ No newline at end of file +prettytable +vedo>=2024.5.1 \ No newline at end of file diff --git a/rocketpy/plots/assets/default_rocket.stl b/rocketpy/plots/assets/default_rocket.stl new file mode 100644 index 000000000..e3889fe36 --- /dev/null +++ b/rocketpy/plots/assets/default_rocket.stl @@ -0,0 +1,16 @@ +solid default_rocket + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet + facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 0 1 0 + vertex 1 0 0 + endloop + endfacet +endsolid default_rocket diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py index 7eb41a8b2..a7c750dad 100644 --- a/rocketpy/plots/flight_plots.py +++ b/rocketpy/plots/flight_plots.py @@ -1,8 +1,12 @@ +import os +import time from functools import cached_property +from importlib import resources import matplotlib.pyplot as plt import numpy as np +from ..tools import import_optional_dependency from .plot_helpers import show_or_save_plot @@ -133,6 +137,244 @@ def trajectory_3d(self, *, filename=None): # pylint: disable=too-many-statement ax1.set_box_aspect(None, zoom=0.95) # 95% for label adjustment show_or_save_plot(filename) + def _resolve_animation_model_path(self, file_name): + """Resolve model path, defaulting to the built-in STL when omitted.""" + if file_name is not None: + return file_name + + return str( + resources.files("rocketpy.plots").joinpath("assets/default_rocket.stl") + ) + + def _validate_animation_inputs(self, file_name, start, stop, time_step): + """Validate shared input parameters for 3D animation methods.""" + if time_step <= 0: + raise ValueError( + f"Invalid time_step: {time_step}. It must be greater than 0." + ) + + if stop is None: + stop = self.flight.t_final + + if ( + start < 0 + or stop < 0 + or start > self.flight.t_final + or stop > self.flight.t_final + or start >= stop + ): + raise ValueError( + f"Invalid animation time range: start={start}, stop={stop}. " + f"Both must be within [0, {self.flight.t_final}] and start < stop." + ) + + if not os.path.isfile(file_name): + raise FileNotFoundError( + f"Could not find the 3D model file: '{file_name}'. " + "Provide a valid .stl file path." + ) + + return stop + + @staticmethod + def _rotation_from_quaternion(q0, q1, q2, q3): + """Convert unit quaternion to axis-angle representation in degrees.""" + norm = np.sqrt(q0 * q0 + q1 * q1 + q2 * q2 + q3 * q3) + if norm == 0: + return 0.0, (1.0, 0.0, 0.0) + + q0 = q0 / norm + q1 = q1 / norm + q2 = q2 / norm + q3 = q3 / norm + + # q and -q represent the same orientation. Keep q0 non-negative to + # reduce discontinuities in axis-angle interpolation across frames. + if q0 < 0: + q0 = -q0 + q1 = -q1 + q2 = -q2 + q3 = -q3 + + q0 = np.clip(q0, -1.0, 1.0) + angle = 2 * np.arccos(q0) + sin_half = np.sqrt(max(1 - q0 * q0, 0.0)) + + if sin_half < 1e-12: + return 0.0, (1.0, 0.0, 0.0) + + axis = (q1 / sin_half, q2 / sin_half, q3 / sin_half) + return np.degrees(angle), axis + + def _create_animation_box(self, start, scale=1.0): + """Create a world box with minimum visible dimensions.""" + min_box_dim = 10.0 + x_values = self.flight.x[:, 1] + y_values = self.flight.y[:, 1] + z_values = self.flight.z[:, 1] - self.flight.env.elevation + + center_x = 0.5 * (np.max(x_values) + np.min(x_values)) + center_y = 0.5 * (np.max(y_values) + np.min(y_values)) + center_z = max(self.flight.z(start) - self.flight.env.elevation, 0.0) + + length = max(np.ptp(x_values) * scale, min_box_dim) + width = max(np.ptp(y_values) * scale, min_box_dim) + height = max(np.ptp(z_values) * scale, min_box_dim) + + # Keep z center inside visible space while preserving minimum box size. + center_z = max(center_z, 0.5 * min_box_dim) + + vedo = import_optional_dependency("vedo") + + return vedo.Box( + pos=[center_x, center_y, center_z], + length=length, + width=width, + height=height, + ).wireframe() + + def animate_trajectory( # pylint: disable=too-many-statements + self, file_name=None, start=0, stop=None, time_step=0.1, **kwargs + ): + """Animate 6-DOF trajectory and attitude using vedo. + + Parameters + ---------- + file_name : str | None, optional + Path to a 3D model file representing the rocket, usually ``.stl``. + If None, RocketPy uses a built-in default STL model. + Default is None. + start : int, float, optional + Animation start time in seconds. Default is 0. + stop : int, float | None, optional + Animation end time in seconds. If None, uses ``flight.t_final``. + Default is None. + time_step : float, optional + Animation frame step in seconds. Must be greater than 0. + Default is 0.1. + **kwargs : dict, optional + Additional keyword arguments passed to ``vedo.Plotter.show``. + """ + + vedo = import_optional_dependency("vedo") + + file_name = self._resolve_animation_model_path(file_name) + stop = self._validate_animation_inputs(file_name, start, stop, time_step) + + try: + vedo.settings.allow_interaction = True + except AttributeError: + pass + + world = self._create_animation_box(start, scale=1.2) + base_rocket = vedo.Mesh(file_name).c("green") + time_steps = np.arange(start, stop, time_step) + trajectory_points = [] + + plt = vedo.Plotter(axes=1, interactive=False) + plt.show(world, "Rocket Trajectory Animation", viewup="z", **kwargs) + + for t in time_steps: + rocket = base_rocket.clone() + x_position = self.flight.x(t) + y_position = self.flight.y(t) + z_position = self.flight.z(t) - self.flight.env.elevation + + angle_deg, axis = self._rotation_from_quaternion( + self.flight.e0(t), + self.flight.e1(t), + self.flight.e2(t), + self.flight.e3(t), + ) + + rocket.pos(x_position, y_position, z_position) + if angle_deg != 0.0: + rocket.rotate(angle_deg, axis=axis) + + trajectory_points.append([x_position, y_position, z_position]) + actors = [world, rocket] + if len(trajectory_points) > 1: + actors.append(vedo.Line(trajectory_points, c="k", alpha=0.5)) + + plt.show(*actors, resetcam=False) + + start_pause = time.time() + while time.time() - start_pause < time_step: + plt.render() + + if getattr(plt, "escaped", False): + break + + plt.interactive().close() + + def animate_rotate( # pylint: disable=too-many-statements + self, file_name=None, start=0, stop=None, time_step=0.1, **kwargs + ): + """Animate rocket attitude (rotation-only view) using vedo. + + Parameters + ---------- + file_name : str | None, optional + Path to a 3D model file representing the rocket, usually ``.stl``. + If None, RocketPy uses a built-in default STL model. + Default is None. + start : int, float, optional + Animation start time in seconds. Default is 0. + stop : int, float | None, optional + Animation end time in seconds. If None, uses ``flight.t_final``. + Default is None. + time_step : float, optional + Animation frame step in seconds. Must be greater than 0. + Default is 0.1. + **kwargs : dict, optional + Additional keyword arguments passed to ``vedo.Plotter.show``. + """ + + vedo = import_optional_dependency("vedo") + + file_name = self._resolve_animation_model_path(file_name) + stop = self._validate_animation_inputs(file_name, start, stop, time_step) + + try: + vedo.settings.allow_interaction = True + except AttributeError: + pass + + world = self._create_animation_box(start, scale=0.3) + base_rocket = vedo.Mesh(file_name).c("green") + time_steps = np.arange(start, stop, time_step) + + x_start = self.flight.x(start) + y_start = self.flight.y(start) + z_start = self.flight.z(start) - self.flight.env.elevation + + plt = vedo.Plotter(axes=1, interactive=False) + plt.show(world, "Rocket Rotation Animation", viewup="z", **kwargs) + + for t in time_steps: + rocket = base_rocket.clone() + angle_deg, axis = self._rotation_from_quaternion( + self.flight.e0(t), + self.flight.e1(t), + self.flight.e2(t), + self.flight.e3(t), + ) + + rocket.pos(x_start, y_start, z_start) + if angle_deg != 0.0: + rocket.rotate(angle_deg, axis=axis) + + plt.show(world, rocket, resetcam=False) + + start_pause = time.time() + while time.time() - start_pause < time_step: + plt.render() + + if getattr(plt, "escaped", False): + break + + plt.interactive().close() + def linear_kinematics_data(self, *, filename=None): # pylint: disable=too-many-statements """Prints out all Kinematics graphs available about the Flight diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index fc412d3b9..f4a218995 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -1,4 +1,7 @@ +import builtins import os +import sys +import types from unittest.mock import MagicMock, patch import matplotlib.pyplot as plt @@ -171,3 +174,200 @@ def test_animate_fluid_volume(example_mass_flow_rate_based_tank_seblm): assert os.path.exists("test_fluid_volume.gif") os.remove("test_fluid_volume.gif") + + +class _DummyVedoActor: + """Minimal actor mock that supports the methods used by animation plots.""" + + def __init__(self): + self.rotations = [] + + def c(self, *_args, **_kwargs): + return self + + def pos(self, *_args, **_kwargs): + return self + + def wireframe(self): + return self + + def rotate(self, angle, axis=None): + self.rotations.append((angle, axis)) + return self + + def clone(self): + return _DummyVedoActor() + + +class _DummyPlotter: + """Minimal plotter mock for non-interactive animation tests.""" + + def __init__(self, *_args, **_kwargs): + self.escaped = False + + def show(self, *_args, **_kwargs): + return self + + def render(self): + return None + + def interactive(self): + return self + + def close(self): + return None + + +def _mock_vedo_module(monkeypatch): + """Install a minimal vedo module in sys.modules for tests.""" + + vedo_module = types.ModuleType("vedo") + vedo_module.Mesh = lambda *_args, **_kwargs: _DummyVedoActor() + vedo_module.Box = lambda *_args, **_kwargs: _DummyVedoActor() + vedo_module.Line = lambda *_args, **_kwargs: _DummyVedoActor() + vedo_module.Plotter = _DummyPlotter + vedo_module.settings = types.SimpleNamespace() + monkeypatch.setitem(sys.modules, "vedo", vedo_module) + + +def test_animate_trajectory_runs_with_mocked_vedo(flight_calisto, monkeypatch): + """Test flight trajectory animation entry point through the plots layer.""" + + # Arrange + _mock_vedo_module(monkeypatch) + + # Act + result = flight_calisto.plots.animate_trajectory( + start=0.0, + stop=0.001, + time_step=0.001, + ) + + # Assert + assert result is None + + +def test_animate_rotate_runs_with_mocked_vedo(flight_calisto, monkeypatch): + """Test flight rotation animation entry point through the plots layer.""" + + # Arrange + _mock_vedo_module(monkeypatch) + + # Act + result = flight_calisto.plots.animate_rotate( + start=0.0, + stop=0.001, + time_step=0.001, + ) + + # Assert + assert result is None + + +def test_animate_trajectory_raises_when_vedo_is_missing(flight_calisto, monkeypatch): + """Test that an informative ImportError is raised when vedo is unavailable.""" + + # Arrange + real_import = builtins.__import__ + + def import_without_vedo(name, *args, **kwargs): + if name == "vedo" or name.startswith("vedo."): + raise ImportError("No module named 'vedo'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_without_vedo) + + # Act / Assert + with pytest.raises(ImportError, match="optional dependency"): + flight_calisto.plots.animate_trajectory( + start=0.0, + stop=0.001, + time_step=0.001, + ) + + +def test_animate_rotate_raises_when_time_range_is_invalid(flight_calisto, monkeypatch): + """Test validation error for invalid animation time range.""" + + # Arrange + _mock_vedo_module(monkeypatch) + # Act / Assert + with pytest.raises(ValueError, match="Invalid animation time range"): + flight_calisto.plots.animate_rotate( + start=1.0, + stop=0.5, + time_step=0.1, + ) + + +def test_animate_trajectory_raises_when_stl_file_is_missing( + flight_calisto, monkeypatch +): + """Test file validation when STL path does not exist.""" + + # Arrange + _mock_vedo_module(monkeypatch) + + # Act / Assert + with pytest.raises(FileNotFoundError, match="Could not find the 3D model file"): + flight_calisto.plots.animate_trajectory( + "missing_model.stl", + start=0.0, + stop=0.1, + time_step=0.1, + ) + + +@pytest.mark.parametrize("invalid_time_step", [0, -0.1]) +def test_animate_trajectory_raises_when_time_step_is_non_positive( + flight_calisto, monkeypatch, invalid_time_step +): + """Test validation error when animation time_step is not strictly positive.""" + + # Arrange + _mock_vedo_module(monkeypatch) + # Act / Assert + with pytest.raises(ValueError, match="Invalid time_step"): + flight_calisto.plots.animate_trajectory( + start=0.0, + stop=0.1, + time_step=invalid_time_step, + ) + + +def test_animate_rotate_raises_when_stop_exceeds_flight_end( + flight_calisto, monkeypatch +): + """Test validation error when stop time exceeds available simulation range.""" + + # Arrange + _mock_vedo_module(monkeypatch) + # Act / Assert + with pytest.raises(ValueError, match="Invalid animation time range"): + flight_calisto.plots.animate_rotate( + start=0.0, + stop=flight_calisto.t_final + 0.1, + time_step=0.1, + ) + + +def test_animate_trajectory_raises_when_default_model_is_missing( + flight_calisto, monkeypatch +): + """Test failure path when default packaged STL model is unavailable.""" + + # Arrange + _mock_vedo_module(monkeypatch) + monkeypatch.setattr( + flight_calisto.plots, + "_resolve_animation_model_path", + lambda _file_name: "missing_default_model.stl", + ) + + # Act / Assert + with pytest.raises(FileNotFoundError, match="Could not find the 3D model file"): + flight_calisto.plots.animate_trajectory( + start=0.0, + stop=0.1, + time_step=0.1, + )