-
-
Notifications
You must be signed in to change notification settings - Fork 228
feat: Implement 3D flight animation methods using Vedo (Issue #523) #909
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
8cd8ecc
5161699
62912d7
62aef8b
aebdf6a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| # pylint: disable=too-many-lines | ||||||||||||||||||||||||||||||||||||||||||||||
| import math | ||||||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||||||
| import warnings | ||||||||||||||||||||||||||||||||||||||||||||||
| from copy import deepcopy | ||||||||||||||||||||||||||||||||||||||||||||||
| from functools import cached_property | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -4039,7 +4040,202 @@ def export_kml( | |||||||||||||||||||||||||||||||||||||||||||||
| color=color, | ||||||||||||||||||||||||||||||||||||||||||||||
| altitude_mode=altitude_mode, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| def animate_trajectory(self, file_name, start=0, stop=None, time_step=0.1, **kwargs): | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| 6-DOF Animation of the flight trajectory using Vedo. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Parameters | ||||||||||||||||||||||||||||||||||||||||||||||
| ---------- | ||||||||||||||||||||||||||||||||||||||||||||||
| file_name : str | ||||||||||||||||||||||||||||||||||||||||||||||
| 3D object file representing your rocket, usually in .stl format. | ||||||||||||||||||||||||||||||||||||||||||||||
| Example: "rocket.stl" | ||||||||||||||||||||||||||||||||||||||||||||||
| start : int, float, optional | ||||||||||||||||||||||||||||||||||||||||||||||
| Time for starting animation, in seconds. Default is 0. | ||||||||||||||||||||||||||||||||||||||||||||||
| stop : int, float, optional | ||||||||||||||||||||||||||||||||||||||||||||||
| Time for ending animation, in seconds. If None, uses self.t_final. | ||||||||||||||||||||||||||||||||||||||||||||||
| Default is None. | ||||||||||||||||||||||||||||||||||||||||||||||
| time_step : float, optional | ||||||||||||||||||||||||||||||||||||||||||||||
| Time step for data interpolation in the animation. Default is 0.1. | ||||||||||||||||||||||||||||||||||||||||||||||
| **kwargs : dict, optional | ||||||||||||||||||||||||||||||||||||||||||||||
| Additional keyword arguments to be passed to vedo.Plotter.show(). | ||||||||||||||||||||||||||||||||||||||||||||||
| Common arguments: | ||||||||||||||||||||||||||||||||||||||||||||||
| - azimuth (float): Rotation in degrees around the vertical axis. | ||||||||||||||||||||||||||||||||||||||||||||||
| - elevation (float): Rotation in degrees above the horizon. | ||||||||||||||||||||||||||||||||||||||||||||||
| - roll (float): Rotation in degrees around the view axis. | ||||||||||||||||||||||||||||||||||||||||||||||
| - zoom (float): Zoom level (default 1). | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Returns | ||||||||||||||||||||||||||||||||||||||||||||||
| ------- | ||||||||||||||||||||||||||||||||||||||||||||||
| None | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Raises | ||||||||||||||||||||||||||||||||||||||||||||||
| ------ | ||||||||||||||||||||||||||||||||||||||||||||||
| ImportError | ||||||||||||||||||||||||||||||||||||||||||||||
| If the 'vedo' package is not installed. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Notes | ||||||||||||||||||||||||||||||||||||||||||||||
| ----- | ||||||||||||||||||||||||||||||||||||||||||||||
| This feature requires the 'vedo' package. Install it with: | ||||||||||||||||||||||||||||||||||||||||||||||
| pip install rocketpy[animation] | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| from vedo import Box, Line, Mesh, Plotter, settings | ||||||||||||||||||||||||||||||||||||||||||||||
| except ImportError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise ImportError( | ||||||||||||||||||||||||||||||||||||||||||||||
| "The animation feature requires the 'vedo' package. " | ||||||||||||||||||||||||||||||||||||||||||||||
| "Install it with:\n" | ||||||||||||||||||||||||||||||||||||||||||||||
| " pip install rocketpy[animation]\n" | ||||||||||||||||||||||||||||||||||||||||||||||
| "Or directly:\n" | ||||||||||||||||||||||||||||||||||||||||||||||
| " pip install vedo>=2024.5.1" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) from e | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Enable interaction if needed | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| settings.allow_interaction = True | ||||||||||||||||||||||||||||||||||||||||||||||
| except AttributeError: | ||||||||||||||||||||||||||||||||||||||||||||||
| pass # Not available in newer versions of vedo | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Handle stop time | ||||||||||||||||||||||||||||||||||||||||||||||
| if stop is None: | ||||||||||||||||||||||||||||||||||||||||||||||
| stop = self.t_final | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Validate start and stop times | |
| if ( | |
| start < 0 | |
| or stop < 0 | |
| or start > self.t_final | |
| or stop > self.t_final | |
| or start >= stop | |
| ): | |
| raise ValueError( | |
| f"Invalid animation time range: start={start}, stop={stop}. " | |
| f"Both must be within [0, {self.t_final}] and start < stop." | |
| ) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing file validation: The method doesn't check if the specified STL file exists before attempting to load it. Add a check to raise a FileNotFoundError with a helpful message if the file doesn't exist, similar to the pattern used elsewhere in the codebase.
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The add_trail() method is called with n=len(self.x[:, 1]) but this appears to conflict with the manual trail creation at lines 4117-4119. The add_trail() call is typically used for automatic trailing in vedo, but here you're also manually creating a Line object for the trail. Either use vedo's built-in trailing mechanism or the manual Line approach, not both. The manual Line approach seems more appropriate given your control over the trail points.
| rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) | |
| rocket.pos(self.x(start), self.y(start), 0) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The trail is created using all trajectory points from start to stop before the animation loop, but it's only added to the scene once at line 4143. This means the trail shows the entire future path before the rocket animates, which may not be the desired behavior. Consider either:
- Building the trail incrementally during the animation loop, or
- Removing the trail entirely if it's meant to show only the past trajectory, or
- Clarifying in documentation that the trail shows the full trajectory path upfront.
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of __doc__ as a display string is incorrect. __doc__ refers to the module/function's docstring and will display a large block of text. If you want to show a title or message, pass a proper string like "Flight Trajectory Animation" instead.
| plt.show(world, rocket, __doc__, viewup="z", **kwargs) | |
| plt.show(world, rocket, "Flight Trajectory Animation", viewup="z", **kwargs) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential numerical instability: np.arccos(2 * self.e0(t)**2 - 1) can fail if the argument is slightly outside [-1, 1] due to numerical precision. Use np.clip() to ensure the argument is within valid range: np.arccos(np.clip(2 * self.e0(t)**2 - 1, -1, 1)).
| angle = np.arccos(2 * self.e0(t)**2 - 1) | |
| angle = np.arccos(np.clip(2 * self.e0(t)**2 - 1, -1, 1)) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The quaternion-to-rotation conversion logic has several issues:
-
The rotation is being applied incrementally in each animation frame without resetting the mesh orientation. This will cause cumulative rotation errors as
rotate_x/y/zmethods apply rotations relative to the current orientation, not the initial state. -
The quaternion-to-axis-angle conversion formula appears incorrect. For a unit quaternion (e0, e1, e2, e3), the angle should be
2 * arccos(e0), notarccos(2*e0^2 - 1). -
The rotation axis components should be normalized by
sin(angle/2), which equalssqrt(e1^2 + e2^2 + e3^2), not the computedkvalue.
To fix this, you should either:
- Reset the mesh orientation before each frame and apply the absolute rotation, or
- Use vedo's
orientation()method with a rotation matrix derived from the quaternion, or - Store the initial mesh orientation and compute relative rotations properly.
The same issue exists in the animate_rotate method at lines 4222-4229.
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Division by zero protection is incomplete. When np.sin(angle / 2) == 0, setting k = 1 still leads to division by potentially zero values in the quaternion components (e1, e2, e3). If the angle is 0 (identity quaternion), all quaternion vector components should also be 0, making e1/k, e2/k, e3/k undefined. Consider handling the identity quaternion case explicitly by skipping rotation when angle is close to zero.
| angle = np.arccos(2 * self.e0(t)**2 - 1) | |
| k = np.sin(angle / 2) if np.sin(angle / 2) != 0 else 1 | |
| # Update position and rotation | |
| # Adjusting for ground elevation | |
| rocket.pos(self.x(t), self.y(t), self.z(t) - self.env.elevation) | |
| rocket.rotate_x(self.e1(t) / k) | |
| rocket.rotate_y(self.e2(t) / k) | |
| rocket.rotate_z(self.e3(t) / k) | |
| angle = np.arccos(2 * self.e0(t)**2 - 1) | |
| # If angle is very small, skip rotation (identity quaternion) | |
| if np.isclose(angle, 0.0, atol=1e-8): | |
| # Update position only | |
| rocket.pos(self.x(t), self.y(t), self.z(t) - self.env.elevation) | |
| else: | |
| k = np.sin(angle / 2) | |
| # Update position and rotation | |
| rocket.pos(self.x(t), self.y(t), self.z(t) - self.env.elevation) | |
| rocket.rotate_x(self.e1(t) / k) | |
| rocket.rotate_y(self.e2(t) / k) | |
| rocket.rotate_z(self.e3(t) / k) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The timing control using time.sleep() in a while loop is inefficient and may cause performance issues. Consider using a simpler approach like time.sleep(time_step) directly, or better yet, let vedo handle the animation timing if it provides such functionality. The current implementation will consume CPU cycles unnecessarily during the sleep period.
| start_pause = time.time() | |
| while time.time() - start_pause < time_step: | |
| plt.render() | |
| time.sleep(time_step) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new animation methods lack unit test coverage. While a verification script exists in tests/animation_verification/, there are no pytest-based unit tests that can be run as part of the CI pipeline. Consider adding tests to tests/unit/simulation/test_flight.py that:
- Mock the vedo import and verify the ImportError is raised when vedo is not installed
- Test parameter validation (e.g., invalid time ranges, missing files)
- Verify the methods can be called with vedo installed (even if just checking they don't raise exceptions)
This follows the testing pattern used for other Flight methods and ensures the feature doesn't break in future refactorings.
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing input validation: The method doesn't validate that start < stop or that these values are within the valid time range [0, self.t_final]. Add validation to raise a ValueError if start >= stop or if either value is outside the valid simulation time range.
| # Validate start and stop times | |
| if not (0 <= start < stop <= self.t_final): | |
| raise ValueError( | |
| f"Invalid animation time range: start={start}, stop={stop}. " | |
| f"Both must satisfy 0 <= start < stop <= {self.t_final}." | |
| ) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential issue with Box dimensions: When trajectory has small or zero displacement in x or y directions, the world box can have zero or very small dimensions (e.g., max_x * 0.2 when max_x is small). This could result in invisible or improperly sized bounding boxes. Add minimum dimension checks to ensure the box remains visible, e.g., max(self.x[:, 1]) * 0.2 if max(self.x[:, 1]) > 10 else 10.
| world = Box( | |
| pos=[self.x(start), self.y(start), self.apogee], | |
| length=max(self.x[:, 1]) * 0.2, | |
| width=max(self.y[:, 1]) * 0.2, | |
| min_box_dim = 10 # meters, minimum box dimension for visibility | |
| world = Box( | |
| pos=[self.x(start), self.y(start), self.apogee], | |
| length=max(max(self.x[:, 1]) * 0.2, min_box_dim), | |
| width=max(max(self.y[:, 1]) * 0.2, min_box_dim), |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing file validation: The method doesn't check if the specified STL file exists before attempting to load it. Add a check to raise a FileNotFoundError with a helpful message if the file doesn't exist.
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The add_trail() method is called with n=len(self.x[:, 1]) but in the animate_rotate method, the trail doesn't make sense since the position is static (kept at start). Remove the add_trail() call from this method as it serves no purpose when the rocket doesn't translate.
| rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) | |
| rocket.pos(self.x(start), self.y(start), 0) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of __doc__ as a display string is incorrect. __doc__ refers to the module/function's docstring and will display a large block of text. If you want to show a title or message, pass a proper string like "Rocket Rotation Animation" instead.
| plt.show(world, rocket, __doc__, viewup="z", **kwargs) | |
| plt.show(world, rocket, "Rocket Rotation Animation", viewup="z", **kwargs) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential numerical instability: Same issue as in animate_trajectory. Use np.clip() to ensure the arccos argument is within valid range: np.arccos(np.clip(2 * self.e0(t)**2 - 1, -1, 1)).
| angle = np.arccos(2 * self.e0(t)**2 - 1) | |
| angle = np.arccos(np.clip(2 * self.e0(t)**2 - 1, -1, 1)) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment contains a typo: "slow down to make animation visible" should better explain what's happening. However, more importantly, there's no actual pause mechanism in the animate_rotate method (unlike animate_trajectory), which means the rotation animation will run at maximum speed without frame-rate control. Add a similar timing control as in animate_trajectory to maintain consistent animation speed.
| # Pause to maintain consistent animation speed and make each frame visible | |
| time.sleep(time_step) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import os | ||
|
||
| import traceback | ||
| from rocketpy import Environment, Flight | ||
| from rocket_stl import create_rocket_stl | ||
| from rocket_setup import get_calisto_rocket | ||
|
|
||
|
|
||
| def run_simulation_and_test_animation(): | ||
| print("🚀 Setting up simulation (Calisto Example)...") | ||
|
|
||
| # 1. Setup Environment | ||
| env = Environment(latitude=32.990254, longitude=-106.974998, elevation=1400) | ||
| env.set_date((2025, 12, 5, 12)) | ||
| env.set_atmospheric_model(type="standard_atmosphere") | ||
|
|
||
| # 2. Get Rocket | ||
| try: | ||
| calisto = get_calisto_rocket() | ||
| except Exception as e: | ||
| print(f"❌ Failed to configure rocket: {e}") | ||
| return | ||
|
|
||
| # 3. Simulate Flight | ||
| test_flight = Flight( | ||
| rocket=calisto, environment=env, rail_length=5.2, inclination=85, heading=0 | ||
| ) | ||
|
|
||
| print(f"✅ Flight simulated successfully! Apogee: {test_flight.apogee:.2f} m") | ||
|
|
||
| # 4. Test Animation Methods | ||
| stl_file = "rocket_model.stl" | ||
| # Note: Depending on where you run this, you might need to adjust imports | ||
| # or ensure create_rocket_stl is available in scope. | ||
| create_rocket_stl(stl_file, length=300, radius=50) | ||
|
|
||
| print("\n🎥 Testing animate_trajectory()...") | ||
|
|
||
| try: | ||
| test_flight.animate_trajectory( | ||
| file_name=stl_file, | ||
| stop=15.0, | ||
| time_step=0.05, | ||
| azimuth=-45, # Rotates view 45 degrees left | ||
| elevation=30, # Tilts view 30 degrees up | ||
| zoom=1.2, | ||
| ) | ||
| print("✅ animate_trajectory() executed successfully.") | ||
| except Exception as e: | ||
| print(f"❌ animate_trajectory() Failed: {e}") | ||
| traceback.print_exc() | ||
|
|
||
| print("\n🔄 Testing animate_rotate()...") | ||
|
|
||
| try: | ||
| test_flight.animate_rotate( | ||
| file_name=stl_file, | ||
| time_step=1.0, | ||
| azimuth=-45, # Rotates view 45 degrees left | ||
| elevation=30, # Tilts view 30 degrees up | ||
| zoom=1.2, | ||
| ) | ||
| print("✅ animate_rotate() executed successfully.") | ||
| except Exception as e: | ||
| print(f"❌ animate_rotate() Failed: {e}") | ||
| traceback.print_exc() | ||
|
|
||
| # Cleanup | ||
| if os.path.exists(stl_file): | ||
| os.remove(stl_file) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| run_simulation_and_test_animation() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the new methods should be placed within the
rocketpy/plotsfolder and - maybe- imported to be reused in this file. We usually avoid to define plots inside the simulation.py file.