From 8cd8eccb3f70f720f590ee0eb7cffb34d2b7c945 Mon Sep 17 00:00:00 2001 From: GuilhermeAsura Date: Sat, 6 Dec 2025 00:29:05 -0300 Subject: [PATCH 01/12] build: add 'vedo' as optional dependency for animation --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 48aeb8bc6..a3dbcd5e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,11 @@ monte-carlo = [ "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] From 5161699bd51e74d3a01872836d3289fc3ccbdf0f Mon Sep 17 00:00:00 2001 From: GuilhermeAsura Date: Sat, 6 Dec 2025 00:33:38 -0300 Subject: [PATCH 02/12] feat: add animate_trajectory and animate_rotate to Flight class - Ported methods from legacy 'animate_flight' branch - Adapted to new Flight class structure (removed postProcess) - Added vedo as optional dependency Co-authored-by: Patrick Sampaio --- rocketpy/simulation/flight.py | 196 ++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index a7e0374b2..82f8c99f4 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -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 + + # Define the world bounds based on trajectory + max_x = max(self.x[:, 1]) + max_y = max(self.y[:, 1]) + # Use simple logic for bounds + world = Box( + pos=[self.x(start), self.y(start), self.apogee], + length=max_x * 2 if max_x != 0 else 1000, + width=max_y * 2 if max_y != 0 else 1000, + height=self.apogee, + ).wireframe() + + # Load rocket mesh + rocket = Mesh(file_name).c("green") + rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) + # Create trail + trail_points = [[self.x(t), self.y(t), self.z(t) - self.env.elevation] + for t in np.arange(start, stop, time_step)] + trail = Line(trail_points, c="k", alpha=0.5) + # Setup Plotter + plt = Plotter(axes=1, interactive=False) + plt.show(world, rocket, __doc__, viewup="z", **kwargs) + + # Animation Loop + for t in np.arange(start, stop, time_step): + # Calculate rotation angle and vector from quaternions + # Note: This simple rotation logic mimics the old branch. + # Ideally, vedo handles orientation via matrix, but we stick + # to the provided logic for now. + + # e0 is the scalar part of the quaternion + 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) + + # update the scene + plt.show(world, rocket, trail) + + # slow down to make animation visible + start_pause = time.time() + while time.time() - start_pause < time_step: + plt.render() + + if getattr(plt, 'escaped', False): + break + + plt.interactive().close() + return None + + def animate_rotate(self, file_name, start=0, stop=None, time_step=0.1, **kwargs): + """ + Animation of rocket attitude (rotation) during the flight. + Parameters + ---------- + file_name : str + 3D object file representing your rocket, usually in .stl format. + 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. 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. + """ + try: + from vedo import Box, 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" + ) from e + + # Enable interaction if needed + try: + settings.allow_interaction = True + except AttributeError: + pass # Not available in newer versions of vedo + + if stop is None: + stop = self.t_final + + # Smaller box for rotation view + 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, + height=self.apogee * 0.1, + ).wireframe() + + rocket = Mesh(file_name).c("green") + # Initialize at origin relative to view + rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) + + plt = Plotter(axes=1, interactive=False) + plt.show(world, rocket, __doc__, viewup="z", **kwargs) + + for t in np.arange(start, stop, time_step): + angle = np.arccos(2 * self.e0(t)**2 - 1) + k = np.sin(angle / 2) if np.sin(angle / 2) != 0 else 1 + + # Keep position static (relative start) to observe only rotation + rocket.pos(self.x(start), self.y(start), 0) + rocket.rotate_x(self.e1(t) / k) + rocket.rotate_y(self.e2(t) / k) + rocket.rotate_z(self.e3(t) / k) + + plt.show(world, rocket) + + if getattr(plt, 'escaped', False): + break + + plt.interactive().close() + return None + def info(self): """Prints out a summary of the data available about the Flight.""" self.prints.all() From 62912d79e1bff33830c9a37b03e7d72c2c7720b4 Mon Sep 17 00:00:00 2001 From: GuilhermeAsura Date: Sat, 6 Dec 2025 00:34:09 -0300 Subject: [PATCH 03/12] test: add modular verification suite for 3D animation methods --- tests/animation_verification/__init__.py | 0 tests/animation_verification/main.py | 71 ++++++ tests/animation_verification/rocket_setup.py | 82 +++++++ tests/animation_verification/rocket_stl.py | 220 +++++++++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 tests/animation_verification/__init__.py create mode 100644 tests/animation_verification/main.py create mode 100644 tests/animation_verification/rocket_setup.py create mode 100644 tests/animation_verification/rocket_stl.py diff --git a/tests/animation_verification/__init__.py b/tests/animation_verification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/animation_verification/main.py b/tests/animation_verification/main.py new file mode 100644 index 000000000..7779b3f76 --- /dev/null +++ b/tests/animation_verification/main.py @@ -0,0 +1,71 @@ +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() \ No newline at end of file diff --git a/tests/animation_verification/rocket_setup.py b/tests/animation_verification/rocket_setup.py new file mode 100644 index 000000000..0dc337728 --- /dev/null +++ b/tests/animation_verification/rocket_setup.py @@ -0,0 +1,82 @@ +import os +from rocketpy import SolidMotor, Rocket + +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(os.path.dirname(CURRENT_DIR)) +DATA_DIR = os.path.join(ROOT_DIR, "data") + +OFF_DRAG_PATH = os.path.join(DATA_DIR, "rockets/calisto/powerOffDragCurve.csv") +ON_DRAG_PATH = os.path.join(DATA_DIR, "rockets/calisto/powerOnDragCurve.csv") +AIRFOIL_PATH = os.path.join(DATA_DIR, "airfoils/NACA0012-radians.txt") +MOTOR_PATH = os.path.join(DATA_DIR, "motors/cesaroni/Cesaroni_M1670.eng") + +def get_motor(): + """Locates the motor file and returns a configured SolidMotor object.""" + # We can now point directly to the file without searching + if not os.path.exists(MOTOR_PATH): + raise FileNotFoundError(f"Could not find Cesaroni_M1670.eng at: {MOTOR_PATH}") + + return SolidMotor( + thrust_source=MOTOR_PATH, + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + +def get_calisto_rocket(): + """Configures and returns the Calisto Rocket object.""" + motor = get_motor() + + calisto = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag=OFF_DRAG_PATH, + power_on_drag=ON_DRAG_PATH, + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + + calisto.add_motor(motor, position=-1.255) + calisto.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + + # Aerodynamic surfaces + calisto.add_nose(length=0.55829, kind="vonKarman", position=1.27) + calisto.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + position=-1.04956, + cant_angle=0, + airfoil=(AIRFOIL_PATH, "radians"), + ) + calisto.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + + # Parachutes + calisto.add_parachute( + name="Main", cd_s=10.0, trigger=800, sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + ) + calisto.add_parachute( + name="Drogue", cd_s=1.0, trigger="apogee", sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + ) + + return calisto \ No newline at end of file diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py new file mode 100644 index 000000000..7f91678c0 --- /dev/null +++ b/tests/animation_verification/rocket_stl.py @@ -0,0 +1,220 @@ +import math +import numpy as np +from stl import mesh # Requires numpy-stl package: pip install numpy-stl +import struct + +def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): + """ + Creates a detailed rocket-shaped STL file with proper scale. + + Parameters + ---------- + filename : str + Output filename + length : float + Rocket length in meters + radius : float + Rocket base radius in meters + """ + + # More realistic proportions + nose_length = length * 0.25 + body_length = length * 0.65 + engine_length = length * 0.1 + + # Fin parameters + fin_height = radius * 3 + fin_width = radius * 2 + fin_thickness = radius * 0.2 + num_fins = 4 + + # Calculate number of vertices for smooth curves + num_segments = 36 + vertices = [] + faces = [] + + # Helper function to add triangle + def add_triangle(v1, v2, v3): + nonlocal faces + idx = len(vertices) + vertices.extend([v1, v2, v3]) + faces.append([idx, idx+1, idx+2]) + + # Helper function to add quadrilateral as two triangles + def add_quad(v1, v2, v3, v4): + add_triangle(v1, v2, v3) + add_triangle(v1, v3, v4) + + # 1. Create nose cone (pointed ogive) + nose_vertices = [] + for i in range(num_segments + 1): + angle = 2 * math.pi * i / num_segments + x = math.cos(angle) * radius + y = math.sin(angle) * radius + z = length # Tip at full length + nose_vertices.append([x, y, z]) + + # Create nose cone triangles + nose_tip = [0, 0, length] + for i in range(num_segments): + v1 = nose_tip + v2 = nose_vertices[i] + v3 = nose_vertices[(i + 1) % num_segments] + add_triangle(v1, v2, v3) + + # 2. Create main body cylinder + body_z_start = length - nose_length + body_z_end = engine_length + + # Create vertices for top and bottom circles of body + top_circle = [] + bottom_circle = [] + + for i in range(num_segments): + angle = 2 * math.pi * i / num_segments + x = math.cos(angle) * radius + y = math.sin(angle) * radius + + top_circle.append([x, y, body_z_start]) + bottom_circle.append([x, y, body_z_end]) + + # Create body cylinder triangles + for i in range(num_segments): + next_i = (i + 1) % num_segments + + # Side quad + v1 = top_circle[i] + v2 = top_circle[next_i] + v3 = bottom_circle[next_i] + v4 = bottom_circle[i] + add_quad(v1, v2, v3, v4) + + # Top circle (connecting to nose) + v1 = top_circle[i] + v2 = top_circle[next_i] + v3 = nose_vertices[i] + add_triangle(v1, v2, v3) + + # 3. Create engine nozzle (truncated cone) + engine_radius = radius * 0.7 + engine_z_end = 0 + + # Create vertices for engine circles + engine_top_circle = [] + engine_bottom_circle = [] + + for i in range(num_segments): + angle = 2 * math.pi * i / num_segments + x_top = math.cos(angle) * radius + y_top = math.sin(angle) * radius + x_bottom = math.cos(angle) * engine_radius + y_bottom = math.sin(angle) * engine_radius + + engine_top_circle.append([x_top, y_top, body_z_end]) + engine_bottom_circle.append([x_bottom, y_bottom, engine_z_end]) + + # Create engine nozzle triangles + for i in range(num_segments): + next_i = (i + 1) % num_segments + + # Side quad + v1 = engine_top_circle[i] + v2 = engine_top_circle[next_i] + v3 = engine_bottom_circle[next_i] + v4 = engine_bottom_circle[i] + add_quad(v1, v2, v3, v4) + + # Connect to body + v1 = engine_top_circle[i] + v2 = engine_top_circle[next_i] + v3 = bottom_circle[i] + add_triangle(v1, v2, v3) + + # 4. Create fins + for fin_num in range(num_fins): + fin_angle = 2 * math.pi * fin_num / num_fins + + # Fin vertices + fin_base_center = [0, 0, body_z_end * 0.5] + + # Inner fin edge (attached to rocket) + inner_x = math.cos(fin_angle) * radius + inner_y = math.sin(fin_angle) * radius + inner_top = [inner_x, inner_y, body_z_end + fin_height * 0.3] + inner_bottom = [inner_x, inner_y, body_z_end - fin_height * 0.7] + + # Outer fin edge + outer_x = math.cos(fin_angle) * (radius + fin_width) + outer_y = math.sin(fin_angle) * (radius + fin_width) + outer_top = [outer_x, outer_y, body_z_end + fin_height * 0.3] + outer_bottom = [outer_x, outer_y, body_z_end - fin_height * 0.7] + + # Fin side that attaches to rocket + add_quad(inner_top, inner_bottom, bottom_circle[fin_num * num_segments // num_fins], + bottom_circle[(fin_num * num_segments // num_fins + 1) % num_segments]) + + # Fin surfaces + # Front face + add_quad(inner_top, outer_top, outer_bottom, inner_bottom) + + # Top face + add_triangle(inner_top, outer_top, + [inner_top[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_top[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_top[2]]) + + # Bottom face + add_triangle(inner_bottom, outer_bottom, + [inner_bottom[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_bottom[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_bottom[2]]) + + # 5. Create bottom cap + center_bottom = [0, 0, engine_z_end] + for i in range(num_segments): + next_i = (i + 1) % num_segments + add_triangle(center_bottom, engine_bottom_circle[i], engine_bottom_circle[next_i]) + + # Convert to numpy arrays + vertices_array = np.array(vertices) + faces_array = np.array(faces) + + # Create the mesh + rocket_mesh = mesh.Mesh(np.zeros(faces_array.shape[0], dtype=mesh.Mesh.dtype)) + for i, face in enumerate(faces_array): + for j in range(3): + rocket_mesh.vectors[i][j] = vertices_array[face[j]] + + # Write the mesh to file + rocket_mesh.save(filename) + print(f"Rocket STL saved to {filename}") + print(f"Total triangles: {len(faces)}") + +# Alternative version using pure Python STL generation +def create_rocket_stl_simple(filename="rocket_simple.stl", length=100, radius=10): + """Simpler version using pure Python without numpy-stl dependency""" + + def write_stl(faces, filename): + with open(filename, 'wb') as f: + # Write 80 byte header + f.write(b'\x00' * 80) + # Write number of faces + f.write(struct.pack(' Date: Sat, 6 Dec 2025 15:33:50 -0300 Subject: [PATCH 04/12] maint: fix unused variables and pin requests>=2.25.0 for CI stability --- requirements.txt | 2 +- tests/animation_verification/rocket_stl.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 61a594320..a4fe9cf26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ numpy>=1.13 scipy>=1.0 matplotlib>=3.9.0 # Released May 15th 2024 netCDF4>=1.6.4 -requests +requests>=2.25.0 pytz simplekml dill diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py index 7f91678c0..5b10ccf1b 100644 --- a/tests/animation_verification/rocket_stl.py +++ b/tests/animation_verification/rocket_stl.py @@ -19,7 +19,6 @@ def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): # More realistic proportions nose_length = length * 0.25 - body_length = length * 0.65 engine_length = length * 0.1 # Fin parameters @@ -134,9 +133,6 @@ def add_quad(v1, v2, v3, v4): for fin_num in range(num_fins): fin_angle = 2 * math.pi * fin_num / num_fins - # Fin vertices - fin_base_center = [0, 0, body_z_end * 0.5] - # Inner fin edge (attached to rocket) inner_x = math.cos(fin_angle) * radius inner_y = math.sin(fin_angle) * radius @@ -211,10 +207,6 @@ def write_stl(faces, filename): f.write(struct.pack(' Date: Sat, 6 Dec 2025 23:36:04 -0300 Subject: [PATCH 05/12] style: format verification scripts and pin pytz>=2020.1 for CI stability --- requirements.txt | 2 +- tests/animation_verification/main.py | 28 ++-- tests/animation_verification/rocket_setup.py | 22 +++- tests/animation_verification/rocket_stl.py | 130 +++++++++++-------- 4 files changed, 108 insertions(+), 74 deletions(-) diff --git a/requirements.txt b/requirements.txt index a4fe9cf26..e08c9a81d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ scipy>=1.0 matplotlib>=3.9.0 # Released May 15th 2024 netCDF4>=1.6.4 requests>=2.25.0 -pytz +pytz>=2020.1 simplekml dill diff --git a/tests/animation_verification/main.py b/tests/animation_verification/main.py index 7779b3f76..5538bd7fe 100644 --- a/tests/animation_verification/main.py +++ b/tests/animation_verification/main.py @@ -4,9 +4,10 @@ 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)) @@ -23,40 +24,40 @@ def run_simulation_and_test_animation(): 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 + # 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, + file_name=stl_file, stop=15.0, time_step=0.05, - azimuth=-45, # Rotates view 45 degrees left + azimuth=-45, # Rotates view 45 degrees left elevation=30, # Tilts view 30 degrees up - zoom=1.2 - ) + 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, + file_name=stl_file, time_step=1.0, - azimuth=-45, # Rotates view 45 degrees left + azimuth=-45, # Rotates view 45 degrees left elevation=30, # Tilts view 30 degrees up - zoom=1.2 + zoom=1.2, ) print("āœ… animate_rotate() executed successfully.") except Exception as e: @@ -67,5 +68,6 @@ def run_simulation_and_test_animation(): if os.path.exists(stl_file): os.remove(stl_file) + if __name__ == "__main__": - run_simulation_and_test_animation() \ No newline at end of file + run_simulation_and_test_animation() diff --git a/tests/animation_verification/rocket_setup.py b/tests/animation_verification/rocket_setup.py index 0dc337728..4f3c11ba1 100644 --- a/tests/animation_verification/rocket_setup.py +++ b/tests/animation_verification/rocket_setup.py @@ -10,6 +10,7 @@ AIRFOIL_PATH = os.path.join(DATA_DIR, "airfoils/NACA0012-radians.txt") MOTOR_PATH = os.path.join(DATA_DIR, "motors/cesaroni/Cesaroni_M1670.eng") + def get_motor(): """Locates the motor file and returns a configured SolidMotor object.""" # We can now point directly to the file without searching @@ -35,10 +36,11 @@ def get_motor(): coordinate_system_orientation="nozzle_to_combustion_chamber", ) + def get_calisto_rocket(): """Configures and returns the Calisto Rocket object.""" motor = get_motor() - + calisto = Rocket( radius=127 / 2000, mass=14.426, @@ -73,10 +75,20 @@ def get_calisto_rocket(): # Parachutes calisto.add_parachute( - name="Main", cd_s=10.0, trigger=800, sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + name="Main", + cd_s=10.0, + trigger=800, + sampling_rate=105, + lag=1.5, + noise=(0, 8.3, 0.5), ) calisto.add_parachute( - name="Drogue", cd_s=1.0, trigger="apogee", sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + name="Drogue", + cd_s=1.0, + trigger="apogee", + sampling_rate=105, + lag=1.5, + noise=(0, 8.3, 0.5), ) - - return calisto \ No newline at end of file + + return calisto diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py index 5b10ccf1b..700285d07 100644 --- a/tests/animation_verification/rocket_stl.py +++ b/tests/animation_verification/rocket_stl.py @@ -3,10 +3,11 @@ from stl import mesh # Requires numpy-stl package: pip install numpy-stl import struct + def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): """ Creates a detailed rocket-shaped STL file with proper scale. - + Parameters ---------- filename : str @@ -16,34 +17,34 @@ def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): radius : float Rocket base radius in meters """ - + # More realistic proportions nose_length = length * 0.25 engine_length = length * 0.1 - + # Fin parameters fin_height = radius * 3 fin_width = radius * 2 fin_thickness = radius * 0.2 num_fins = 4 - + # Calculate number of vertices for smooth curves num_segments = 36 vertices = [] faces = [] - + # Helper function to add triangle def add_triangle(v1, v2, v3): nonlocal faces idx = len(vertices) vertices.extend([v1, v2, v3]) - faces.append([idx, idx+1, idx+2]) - + faces.append([idx, idx + 1, idx + 2]) + # Helper function to add quadrilateral as two triangles def add_quad(v1, v2, v3, v4): add_triangle(v1, v2, v3) add_triangle(v1, v3, v4) - + # 1. Create nose cone (pointed ogive) nose_vertices = [] for i in range(num_segments + 1): @@ -52,7 +53,7 @@ def add_quad(v1, v2, v3, v4): y = math.sin(angle) * radius z = length # Tip at full length nose_vertices.append([x, y, z]) - + # Create nose cone triangles nose_tip = [0, 0, length] for i in range(num_segments): @@ -60,153 +61,172 @@ def add_quad(v1, v2, v3, v4): v2 = nose_vertices[i] v3 = nose_vertices[(i + 1) % num_segments] add_triangle(v1, v2, v3) - + # 2. Create main body cylinder body_z_start = length - nose_length body_z_end = engine_length - + # Create vertices for top and bottom circles of body top_circle = [] bottom_circle = [] - + for i in range(num_segments): angle = 2 * math.pi * i / num_segments x = math.cos(angle) * radius y = math.sin(angle) * radius - + top_circle.append([x, y, body_z_start]) bottom_circle.append([x, y, body_z_end]) - + # Create body cylinder triangles for i in range(num_segments): next_i = (i + 1) % num_segments - + # Side quad v1 = top_circle[i] v2 = top_circle[next_i] v3 = bottom_circle[next_i] v4 = bottom_circle[i] add_quad(v1, v2, v3, v4) - + # Top circle (connecting to nose) v1 = top_circle[i] v2 = top_circle[next_i] v3 = nose_vertices[i] add_triangle(v1, v2, v3) - + # 3. Create engine nozzle (truncated cone) engine_radius = radius * 0.7 engine_z_end = 0 - + # Create vertices for engine circles engine_top_circle = [] engine_bottom_circle = [] - + for i in range(num_segments): angle = 2 * math.pi * i / num_segments x_top = math.cos(angle) * radius y_top = math.sin(angle) * radius x_bottom = math.cos(angle) * engine_radius y_bottom = math.sin(angle) * engine_radius - + engine_top_circle.append([x_top, y_top, body_z_end]) engine_bottom_circle.append([x_bottom, y_bottom, engine_z_end]) - + # Create engine nozzle triangles for i in range(num_segments): next_i = (i + 1) % num_segments - + # Side quad v1 = engine_top_circle[i] v2 = engine_top_circle[next_i] v3 = engine_bottom_circle[next_i] v4 = engine_bottom_circle[i] add_quad(v1, v2, v3, v4) - + # Connect to body v1 = engine_top_circle[i] v2 = engine_top_circle[next_i] v3 = bottom_circle[i] add_triangle(v1, v2, v3) - + # 4. Create fins for fin_num in range(num_fins): fin_angle = 2 * math.pi * fin_num / num_fins - + # Inner fin edge (attached to rocket) inner_x = math.cos(fin_angle) * radius inner_y = math.sin(fin_angle) * radius inner_top = [inner_x, inner_y, body_z_end + fin_height * 0.3] inner_bottom = [inner_x, inner_y, body_z_end - fin_height * 0.7] - + # Outer fin edge outer_x = math.cos(fin_angle) * (radius + fin_width) outer_y = math.sin(fin_angle) * (radius + fin_width) outer_top = [outer_x, outer_y, body_z_end + fin_height * 0.3] outer_bottom = [outer_x, outer_y, body_z_end - fin_height * 0.7] - + # Fin side that attaches to rocket - add_quad(inner_top, inner_bottom, bottom_circle[fin_num * num_segments // num_fins], - bottom_circle[(fin_num * num_segments // num_fins + 1) % num_segments]) - + add_quad( + inner_top, + inner_bottom, + bottom_circle[fin_num * num_segments // num_fins], + bottom_circle[(fin_num * num_segments // num_fins + 1) % num_segments], + ) + # Fin surfaces # Front face add_quad(inner_top, outer_top, outer_bottom, inner_bottom) - + # Top face - add_triangle(inner_top, outer_top, - [inner_top[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_top[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_top[2]]) - + add_triangle( + inner_top, + outer_top, + [ + inner_top[0] + math.cos(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_top[1] + math.sin(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_top[2], + ], + ) + # Bottom face - add_triangle(inner_bottom, outer_bottom, - [inner_bottom[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_bottom[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_bottom[2]]) - + add_triangle( + inner_bottom, + outer_bottom, + [ + inner_bottom[0] + + math.cos(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_bottom[1] + + math.sin(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_bottom[2], + ], + ) + # 5. Create bottom cap center_bottom = [0, 0, engine_z_end] for i in range(num_segments): next_i = (i + 1) % num_segments - add_triangle(center_bottom, engine_bottom_circle[i], engine_bottom_circle[next_i]) - + add_triangle( + center_bottom, engine_bottom_circle[i], engine_bottom_circle[next_i] + ) + # Convert to numpy arrays vertices_array = np.array(vertices) faces_array = np.array(faces) - + # Create the mesh rocket_mesh = mesh.Mesh(np.zeros(faces_array.shape[0], dtype=mesh.Mesh.dtype)) for i, face in enumerate(faces_array): for j in range(3): rocket_mesh.vectors[i][j] = vertices_array[face[j]] - + # Write the mesh to file rocket_mesh.save(filename) print(f"Rocket STL saved to {filename}") print(f"Total triangles: {len(faces)}") + # Alternative version using pure Python STL generation def create_rocket_stl_simple(filename="rocket_simple.stl", length=100, radius=10): """Simpler version using pure Python without numpy-stl dependency""" - + def write_stl(faces, filename): - with open(filename, 'wb') as f: + with open(filename, "wb") as f: # Write 80 byte header - f.write(b'\x00' * 80) + f.write(b"\x00" * 80) # Write number of faces - f.write(struct.pack(' Date: Sat, 6 Dec 2025 00:29:05 -0300 Subject: [PATCH 06/12] build: add 'vedo' as optional dependency for animation --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 35ea34382..f7e5d1155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,11 @@ monte-carlo = [ "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] From fc8983f86f90ab2e081a8c6cefe8a1f50734709c Mon Sep 17 00:00:00 2001 From: GuilhermeAsura Date: Sat, 6 Dec 2025 00:33:38 -0300 Subject: [PATCH 07/12] feat: add animate_trajectory and animate_rotate to Flight class - Ported methods from legacy 'animate_flight' branch - Adapted to new Flight class structure (removed postProcess) - Added vedo as optional dependency Co-authored-by: Patrick Sampaio --- rocketpy/simulation/flight.py | 196 ++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index a139a84ee..dacdcb219 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1,5 +1,6 @@ # pylint: disable=too-many-lines import math +import time import warnings from copy import deepcopy from functools import cached_property @@ -4175,7 +4176,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 + + # Define the world bounds based on trajectory + max_x = max(self.x[:, 1]) + max_y = max(self.y[:, 1]) + # Use simple logic for bounds + world = Box( + pos=[self.x(start), self.y(start), self.apogee], + length=max_x * 2 if max_x != 0 else 1000, + width=max_y * 2 if max_y != 0 else 1000, + height=self.apogee, + ).wireframe() + + # Load rocket mesh + rocket = Mesh(file_name).c("green") + rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) + # Create trail + trail_points = [[self.x(t), self.y(t), self.z(t) - self.env.elevation] + for t in np.arange(start, stop, time_step)] + trail = Line(trail_points, c="k", alpha=0.5) + # Setup Plotter + plt = Plotter(axes=1, interactive=False) + plt.show(world, rocket, __doc__, viewup="z", **kwargs) + + # Animation Loop + for t in np.arange(start, stop, time_step): + # Calculate rotation angle and vector from quaternions + # Note: This simple rotation logic mimics the old branch. + # Ideally, vedo handles orientation via matrix, but we stick + # to the provided logic for now. + + # e0 is the scalar part of the quaternion + 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) + + # update the scene + plt.show(world, rocket, trail) + + # slow down to make animation visible + start_pause = time.time() + while time.time() - start_pause < time_step: + plt.render() + + if getattr(plt, 'escaped', False): + break + + plt.interactive().close() + return None + + def animate_rotate(self, file_name, start=0, stop=None, time_step=0.1, **kwargs): + """ + Animation of rocket attitude (rotation) during the flight. + + Parameters + ---------- + file_name : str + 3D object file representing your rocket, usually in .stl format. + 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. 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. + """ + try: + from vedo import Box, 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" + ) from e + + # Enable interaction if needed + try: + settings.allow_interaction = True + except AttributeError: + pass # Not available in newer versions of vedo + + if stop is None: + stop = self.t_final + + # Smaller box for rotation view + 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, + height=self.apogee * 0.1, + ).wireframe() + + rocket = Mesh(file_name).c("green") + # Initialize at origin relative to view + rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) + + plt = Plotter(axes=1, interactive=False) + plt.show(world, rocket, __doc__, viewup="z", **kwargs) + + for t in np.arange(start, stop, time_step): + angle = np.arccos(2 * self.e0(t)**2 - 1) + k = np.sin(angle / 2) if np.sin(angle / 2) != 0 else 1 + + # Keep position static (relative start) to observe only rotation + rocket.pos(self.x(start), self.y(start), 0) + rocket.rotate_x(self.e1(t) / k) + rocket.rotate_y(self.e2(t) / k) + rocket.rotate_z(self.e3(t) / k) + + plt.show(world, rocket) + + if getattr(plt, 'escaped', False): + break + + plt.interactive().close() + return None + def info(self): """Prints out a summary of the data available about the Flight.""" self.prints.all() From c330be7c909150dddaef7443b33c8af2758a8685 Mon Sep 17 00:00:00 2001 From: GuilhermeAsura Date: Sat, 6 Dec 2025 00:34:09 -0300 Subject: [PATCH 08/12] test: add modular verification suite for 3D animation methods --- tests/animation_verification/__init__.py | 0 tests/animation_verification/main.py | 71 ++++++ tests/animation_verification/rocket_setup.py | 82 +++++++ tests/animation_verification/rocket_stl.py | 220 +++++++++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 tests/animation_verification/__init__.py create mode 100644 tests/animation_verification/main.py create mode 100644 tests/animation_verification/rocket_setup.py create mode 100644 tests/animation_verification/rocket_stl.py diff --git a/tests/animation_verification/__init__.py b/tests/animation_verification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/animation_verification/main.py b/tests/animation_verification/main.py new file mode 100644 index 000000000..7779b3f76 --- /dev/null +++ b/tests/animation_verification/main.py @@ -0,0 +1,71 @@ +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() \ No newline at end of file diff --git a/tests/animation_verification/rocket_setup.py b/tests/animation_verification/rocket_setup.py new file mode 100644 index 000000000..0dc337728 --- /dev/null +++ b/tests/animation_verification/rocket_setup.py @@ -0,0 +1,82 @@ +import os +from rocketpy import SolidMotor, Rocket + +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.dirname(os.path.dirname(CURRENT_DIR)) +DATA_DIR = os.path.join(ROOT_DIR, "data") + +OFF_DRAG_PATH = os.path.join(DATA_DIR, "rockets/calisto/powerOffDragCurve.csv") +ON_DRAG_PATH = os.path.join(DATA_DIR, "rockets/calisto/powerOnDragCurve.csv") +AIRFOIL_PATH = os.path.join(DATA_DIR, "airfoils/NACA0012-radians.txt") +MOTOR_PATH = os.path.join(DATA_DIR, "motors/cesaroni/Cesaroni_M1670.eng") + +def get_motor(): + """Locates the motor file and returns a configured SolidMotor object.""" + # We can now point directly to the file without searching + if not os.path.exists(MOTOR_PATH): + raise FileNotFoundError(f"Could not find Cesaroni_M1670.eng at: {MOTOR_PATH}") + + return SolidMotor( + thrust_source=MOTOR_PATH, + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + burn_time=3.9, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + +def get_calisto_rocket(): + """Configures and returns the Calisto Rocket object.""" + motor = get_motor() + + calisto = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag=OFF_DRAG_PATH, + power_on_drag=ON_DRAG_PATH, + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + + calisto.add_motor(motor, position=-1.255) + calisto.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, + ) + + # Aerodynamic surfaces + calisto.add_nose(length=0.55829, kind="vonKarman", position=1.27) + calisto.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + position=-1.04956, + cant_angle=0, + airfoil=(AIRFOIL_PATH, "radians"), + ) + calisto.add_tail( + top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 + ) + + # Parachutes + calisto.add_parachute( + name="Main", cd_s=10.0, trigger=800, sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + ) + calisto.add_parachute( + name="Drogue", cd_s=1.0, trigger="apogee", sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + ) + + return calisto \ No newline at end of file diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py new file mode 100644 index 000000000..7f91678c0 --- /dev/null +++ b/tests/animation_verification/rocket_stl.py @@ -0,0 +1,220 @@ +import math +import numpy as np +from stl import mesh # Requires numpy-stl package: pip install numpy-stl +import struct + +def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): + """ + Creates a detailed rocket-shaped STL file with proper scale. + + Parameters + ---------- + filename : str + Output filename + length : float + Rocket length in meters + radius : float + Rocket base radius in meters + """ + + # More realistic proportions + nose_length = length * 0.25 + body_length = length * 0.65 + engine_length = length * 0.1 + + # Fin parameters + fin_height = radius * 3 + fin_width = radius * 2 + fin_thickness = radius * 0.2 + num_fins = 4 + + # Calculate number of vertices for smooth curves + num_segments = 36 + vertices = [] + faces = [] + + # Helper function to add triangle + def add_triangle(v1, v2, v3): + nonlocal faces + idx = len(vertices) + vertices.extend([v1, v2, v3]) + faces.append([idx, idx+1, idx+2]) + + # Helper function to add quadrilateral as two triangles + def add_quad(v1, v2, v3, v4): + add_triangle(v1, v2, v3) + add_triangle(v1, v3, v4) + + # 1. Create nose cone (pointed ogive) + nose_vertices = [] + for i in range(num_segments + 1): + angle = 2 * math.pi * i / num_segments + x = math.cos(angle) * radius + y = math.sin(angle) * radius + z = length # Tip at full length + nose_vertices.append([x, y, z]) + + # Create nose cone triangles + nose_tip = [0, 0, length] + for i in range(num_segments): + v1 = nose_tip + v2 = nose_vertices[i] + v3 = nose_vertices[(i + 1) % num_segments] + add_triangle(v1, v2, v3) + + # 2. Create main body cylinder + body_z_start = length - nose_length + body_z_end = engine_length + + # Create vertices for top and bottom circles of body + top_circle = [] + bottom_circle = [] + + for i in range(num_segments): + angle = 2 * math.pi * i / num_segments + x = math.cos(angle) * radius + y = math.sin(angle) * radius + + top_circle.append([x, y, body_z_start]) + bottom_circle.append([x, y, body_z_end]) + + # Create body cylinder triangles + for i in range(num_segments): + next_i = (i + 1) % num_segments + + # Side quad + v1 = top_circle[i] + v2 = top_circle[next_i] + v3 = bottom_circle[next_i] + v4 = bottom_circle[i] + add_quad(v1, v2, v3, v4) + + # Top circle (connecting to nose) + v1 = top_circle[i] + v2 = top_circle[next_i] + v3 = nose_vertices[i] + add_triangle(v1, v2, v3) + + # 3. Create engine nozzle (truncated cone) + engine_radius = radius * 0.7 + engine_z_end = 0 + + # Create vertices for engine circles + engine_top_circle = [] + engine_bottom_circle = [] + + for i in range(num_segments): + angle = 2 * math.pi * i / num_segments + x_top = math.cos(angle) * radius + y_top = math.sin(angle) * radius + x_bottom = math.cos(angle) * engine_radius + y_bottom = math.sin(angle) * engine_radius + + engine_top_circle.append([x_top, y_top, body_z_end]) + engine_bottom_circle.append([x_bottom, y_bottom, engine_z_end]) + + # Create engine nozzle triangles + for i in range(num_segments): + next_i = (i + 1) % num_segments + + # Side quad + v1 = engine_top_circle[i] + v2 = engine_top_circle[next_i] + v3 = engine_bottom_circle[next_i] + v4 = engine_bottom_circle[i] + add_quad(v1, v2, v3, v4) + + # Connect to body + v1 = engine_top_circle[i] + v2 = engine_top_circle[next_i] + v3 = bottom_circle[i] + add_triangle(v1, v2, v3) + + # 4. Create fins + for fin_num in range(num_fins): + fin_angle = 2 * math.pi * fin_num / num_fins + + # Fin vertices + fin_base_center = [0, 0, body_z_end * 0.5] + + # Inner fin edge (attached to rocket) + inner_x = math.cos(fin_angle) * radius + inner_y = math.sin(fin_angle) * radius + inner_top = [inner_x, inner_y, body_z_end + fin_height * 0.3] + inner_bottom = [inner_x, inner_y, body_z_end - fin_height * 0.7] + + # Outer fin edge + outer_x = math.cos(fin_angle) * (radius + fin_width) + outer_y = math.sin(fin_angle) * (radius + fin_width) + outer_top = [outer_x, outer_y, body_z_end + fin_height * 0.3] + outer_bottom = [outer_x, outer_y, body_z_end - fin_height * 0.7] + + # Fin side that attaches to rocket + add_quad(inner_top, inner_bottom, bottom_circle[fin_num * num_segments // num_fins], + bottom_circle[(fin_num * num_segments // num_fins + 1) % num_segments]) + + # Fin surfaces + # Front face + add_quad(inner_top, outer_top, outer_bottom, inner_bottom) + + # Top face + add_triangle(inner_top, outer_top, + [inner_top[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_top[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_top[2]]) + + # Bottom face + add_triangle(inner_bottom, outer_bottom, + [inner_bottom[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_bottom[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, + inner_bottom[2]]) + + # 5. Create bottom cap + center_bottom = [0, 0, engine_z_end] + for i in range(num_segments): + next_i = (i + 1) % num_segments + add_triangle(center_bottom, engine_bottom_circle[i], engine_bottom_circle[next_i]) + + # Convert to numpy arrays + vertices_array = np.array(vertices) + faces_array = np.array(faces) + + # Create the mesh + rocket_mesh = mesh.Mesh(np.zeros(faces_array.shape[0], dtype=mesh.Mesh.dtype)) + for i, face in enumerate(faces_array): + for j in range(3): + rocket_mesh.vectors[i][j] = vertices_array[face[j]] + + # Write the mesh to file + rocket_mesh.save(filename) + print(f"Rocket STL saved to {filename}") + print(f"Total triangles: {len(faces)}") + +# Alternative version using pure Python STL generation +def create_rocket_stl_simple(filename="rocket_simple.stl", length=100, radius=10): + """Simpler version using pure Python without numpy-stl dependency""" + + def write_stl(faces, filename): + with open(filename, 'wb') as f: + # Write 80 byte header + f.write(b'\x00' * 80) + # Write number of faces + f.write(struct.pack(' Date: Sat, 6 Dec 2025 15:33:50 -0300 Subject: [PATCH 09/12] maint: fix unused variables and pin requests>=2.25.0 for CI stability --- requirements.txt | 2 +- tests/animation_verification/rocket_stl.py | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 61a594320..a4fe9cf26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ numpy>=1.13 scipy>=1.0 matplotlib>=3.9.0 # Released May 15th 2024 netCDF4>=1.6.4 -requests +requests>=2.25.0 pytz simplekml dill diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py index 7f91678c0..5b10ccf1b 100644 --- a/tests/animation_verification/rocket_stl.py +++ b/tests/animation_verification/rocket_stl.py @@ -19,7 +19,6 @@ def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): # More realistic proportions nose_length = length * 0.25 - body_length = length * 0.65 engine_length = length * 0.1 # Fin parameters @@ -134,9 +133,6 @@ def add_quad(v1, v2, v3, v4): for fin_num in range(num_fins): fin_angle = 2 * math.pi * fin_num / num_fins - # Fin vertices - fin_base_center = [0, 0, body_z_end * 0.5] - # Inner fin edge (attached to rocket) inner_x = math.cos(fin_angle) * radius inner_y = math.sin(fin_angle) * radius @@ -211,10 +207,6 @@ def write_stl(faces, filename): f.write(struct.pack(' Date: Sat, 6 Dec 2025 23:36:04 -0300 Subject: [PATCH 10/12] style: format verification scripts and pin pytz>=2020.1 for CI stability --- requirements.txt | 2 +- tests/animation_verification/main.py | 28 ++-- tests/animation_verification/rocket_setup.py | 22 +++- tests/animation_verification/rocket_stl.py | 130 +++++++++++-------- 4 files changed, 108 insertions(+), 74 deletions(-) diff --git a/requirements.txt b/requirements.txt index a4fe9cf26..e08c9a81d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,6 @@ scipy>=1.0 matplotlib>=3.9.0 # Released May 15th 2024 netCDF4>=1.6.4 requests>=2.25.0 -pytz +pytz>=2020.1 simplekml dill diff --git a/tests/animation_verification/main.py b/tests/animation_verification/main.py index 7779b3f76..5538bd7fe 100644 --- a/tests/animation_verification/main.py +++ b/tests/animation_verification/main.py @@ -4,9 +4,10 @@ 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)) @@ -23,40 +24,40 @@ def run_simulation_and_test_animation(): 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 + # 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, + file_name=stl_file, stop=15.0, time_step=0.05, - azimuth=-45, # Rotates view 45 degrees left + azimuth=-45, # Rotates view 45 degrees left elevation=30, # Tilts view 30 degrees up - zoom=1.2 - ) + 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, + file_name=stl_file, time_step=1.0, - azimuth=-45, # Rotates view 45 degrees left + azimuth=-45, # Rotates view 45 degrees left elevation=30, # Tilts view 30 degrees up - zoom=1.2 + zoom=1.2, ) print("āœ… animate_rotate() executed successfully.") except Exception as e: @@ -67,5 +68,6 @@ def run_simulation_and_test_animation(): if os.path.exists(stl_file): os.remove(stl_file) + if __name__ == "__main__": - run_simulation_and_test_animation() \ No newline at end of file + run_simulation_and_test_animation() diff --git a/tests/animation_verification/rocket_setup.py b/tests/animation_verification/rocket_setup.py index 0dc337728..4f3c11ba1 100644 --- a/tests/animation_verification/rocket_setup.py +++ b/tests/animation_verification/rocket_setup.py @@ -10,6 +10,7 @@ AIRFOIL_PATH = os.path.join(DATA_DIR, "airfoils/NACA0012-radians.txt") MOTOR_PATH = os.path.join(DATA_DIR, "motors/cesaroni/Cesaroni_M1670.eng") + def get_motor(): """Locates the motor file and returns a configured SolidMotor object.""" # We can now point directly to the file without searching @@ -35,10 +36,11 @@ def get_motor(): coordinate_system_orientation="nozzle_to_combustion_chamber", ) + def get_calisto_rocket(): """Configures and returns the Calisto Rocket object.""" motor = get_motor() - + calisto = Rocket( radius=127 / 2000, mass=14.426, @@ -73,10 +75,20 @@ def get_calisto_rocket(): # Parachutes calisto.add_parachute( - name="Main", cd_s=10.0, trigger=800, sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + name="Main", + cd_s=10.0, + trigger=800, + sampling_rate=105, + lag=1.5, + noise=(0, 8.3, 0.5), ) calisto.add_parachute( - name="Drogue", cd_s=1.0, trigger="apogee", sampling_rate=105, lag=1.5, noise=(0, 8.3, 0.5) + name="Drogue", + cd_s=1.0, + trigger="apogee", + sampling_rate=105, + lag=1.5, + noise=(0, 8.3, 0.5), ) - - return calisto \ No newline at end of file + + return calisto diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py index 5b10ccf1b..700285d07 100644 --- a/tests/animation_verification/rocket_stl.py +++ b/tests/animation_verification/rocket_stl.py @@ -3,10 +3,11 @@ from stl import mesh # Requires numpy-stl package: pip install numpy-stl import struct + def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): """ Creates a detailed rocket-shaped STL file with proper scale. - + Parameters ---------- filename : str @@ -16,34 +17,34 @@ def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): radius : float Rocket base radius in meters """ - + # More realistic proportions nose_length = length * 0.25 engine_length = length * 0.1 - + # Fin parameters fin_height = radius * 3 fin_width = radius * 2 fin_thickness = radius * 0.2 num_fins = 4 - + # Calculate number of vertices for smooth curves num_segments = 36 vertices = [] faces = [] - + # Helper function to add triangle def add_triangle(v1, v2, v3): nonlocal faces idx = len(vertices) vertices.extend([v1, v2, v3]) - faces.append([idx, idx+1, idx+2]) - + faces.append([idx, idx + 1, idx + 2]) + # Helper function to add quadrilateral as two triangles def add_quad(v1, v2, v3, v4): add_triangle(v1, v2, v3) add_triangle(v1, v3, v4) - + # 1. Create nose cone (pointed ogive) nose_vertices = [] for i in range(num_segments + 1): @@ -52,7 +53,7 @@ def add_quad(v1, v2, v3, v4): y = math.sin(angle) * radius z = length # Tip at full length nose_vertices.append([x, y, z]) - + # Create nose cone triangles nose_tip = [0, 0, length] for i in range(num_segments): @@ -60,153 +61,172 @@ def add_quad(v1, v2, v3, v4): v2 = nose_vertices[i] v3 = nose_vertices[(i + 1) % num_segments] add_triangle(v1, v2, v3) - + # 2. Create main body cylinder body_z_start = length - nose_length body_z_end = engine_length - + # Create vertices for top and bottom circles of body top_circle = [] bottom_circle = [] - + for i in range(num_segments): angle = 2 * math.pi * i / num_segments x = math.cos(angle) * radius y = math.sin(angle) * radius - + top_circle.append([x, y, body_z_start]) bottom_circle.append([x, y, body_z_end]) - + # Create body cylinder triangles for i in range(num_segments): next_i = (i + 1) % num_segments - + # Side quad v1 = top_circle[i] v2 = top_circle[next_i] v3 = bottom_circle[next_i] v4 = bottom_circle[i] add_quad(v1, v2, v3, v4) - + # Top circle (connecting to nose) v1 = top_circle[i] v2 = top_circle[next_i] v3 = nose_vertices[i] add_triangle(v1, v2, v3) - + # 3. Create engine nozzle (truncated cone) engine_radius = radius * 0.7 engine_z_end = 0 - + # Create vertices for engine circles engine_top_circle = [] engine_bottom_circle = [] - + for i in range(num_segments): angle = 2 * math.pi * i / num_segments x_top = math.cos(angle) * radius y_top = math.sin(angle) * radius x_bottom = math.cos(angle) * engine_radius y_bottom = math.sin(angle) * engine_radius - + engine_top_circle.append([x_top, y_top, body_z_end]) engine_bottom_circle.append([x_bottom, y_bottom, engine_z_end]) - + # Create engine nozzle triangles for i in range(num_segments): next_i = (i + 1) % num_segments - + # Side quad v1 = engine_top_circle[i] v2 = engine_top_circle[next_i] v3 = engine_bottom_circle[next_i] v4 = engine_bottom_circle[i] add_quad(v1, v2, v3, v4) - + # Connect to body v1 = engine_top_circle[i] v2 = engine_top_circle[next_i] v3 = bottom_circle[i] add_triangle(v1, v2, v3) - + # 4. Create fins for fin_num in range(num_fins): fin_angle = 2 * math.pi * fin_num / num_fins - + # Inner fin edge (attached to rocket) inner_x = math.cos(fin_angle) * radius inner_y = math.sin(fin_angle) * radius inner_top = [inner_x, inner_y, body_z_end + fin_height * 0.3] inner_bottom = [inner_x, inner_y, body_z_end - fin_height * 0.7] - + # Outer fin edge outer_x = math.cos(fin_angle) * (radius + fin_width) outer_y = math.sin(fin_angle) * (radius + fin_width) outer_top = [outer_x, outer_y, body_z_end + fin_height * 0.3] outer_bottom = [outer_x, outer_y, body_z_end - fin_height * 0.7] - + # Fin side that attaches to rocket - add_quad(inner_top, inner_bottom, bottom_circle[fin_num * num_segments // num_fins], - bottom_circle[(fin_num * num_segments // num_fins + 1) % num_segments]) - + add_quad( + inner_top, + inner_bottom, + bottom_circle[fin_num * num_segments // num_fins], + bottom_circle[(fin_num * num_segments // num_fins + 1) % num_segments], + ) + # Fin surfaces # Front face add_quad(inner_top, outer_top, outer_bottom, inner_bottom) - + # Top face - add_triangle(inner_top, outer_top, - [inner_top[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_top[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_top[2]]) - + add_triangle( + inner_top, + outer_top, + [ + inner_top[0] + math.cos(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_top[1] + math.sin(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_top[2], + ], + ) + # Bottom face - add_triangle(inner_bottom, outer_bottom, - [inner_bottom[0] + math.cos(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_bottom[1] + math.sin(fin_angle + math.pi/2) * fin_thickness * 0.5, - inner_bottom[2]]) - + add_triangle( + inner_bottom, + outer_bottom, + [ + inner_bottom[0] + + math.cos(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_bottom[1] + + math.sin(fin_angle + math.pi / 2) * fin_thickness * 0.5, + inner_bottom[2], + ], + ) + # 5. Create bottom cap center_bottom = [0, 0, engine_z_end] for i in range(num_segments): next_i = (i + 1) % num_segments - add_triangle(center_bottom, engine_bottom_circle[i], engine_bottom_circle[next_i]) - + add_triangle( + center_bottom, engine_bottom_circle[i], engine_bottom_circle[next_i] + ) + # Convert to numpy arrays vertices_array = np.array(vertices) faces_array = np.array(faces) - + # Create the mesh rocket_mesh = mesh.Mesh(np.zeros(faces_array.shape[0], dtype=mesh.Mesh.dtype)) for i, face in enumerate(faces_array): for j in range(3): rocket_mesh.vectors[i][j] = vertices_array[face[j]] - + # Write the mesh to file rocket_mesh.save(filename) print(f"Rocket STL saved to {filename}") print(f"Total triangles: {len(faces)}") + # Alternative version using pure Python STL generation def create_rocket_stl_simple(filename="rocket_simple.stl", length=100, radius=10): """Simpler version using pure Python without numpy-stl dependency""" - + def write_stl(faces, filename): - with open(filename, 'wb') as f: + with open(filename, "wb") as f: # Write 80 byte header - f.write(b'\x00' * 80) + f.write(b"\x00" * 80) # Write number of faces - f.write(struct.pack(' Date: Sat, 10 Jan 2026 22:21:40 -0300 Subject: [PATCH 11/12] make format --- rocketpy/simulation/flight.py | 41 +++++++++++--------- tests/animation_verification/main.py | 6 ++- tests/animation_verification/rocket_setup.py | 3 +- tests/animation_verification/rocket_stl.py | 3 +- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index dacdcb219..4eb020a6a 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -4176,7 +4176,10 @@ def export_kml( color=color, altitude_mode=altitude_mode, ) - def animate_trajectory(self, file_name, start=0, stop=None, time_step=0.1, **kwargs): + + def animate_trajectory( + self, file_name, start=0, stop=None, time_step=0.1, **kwargs + ): """ 6-DOF Animation of the flight trajectory using Vedo. @@ -4208,7 +4211,7 @@ def animate_trajectory(self, file_name, start=0, stop=None, time_step=0.1, **kwa ------ ImportError If the 'vedo' package is not installed. - + Notes ----- This feature requires the 'vedo' package. Install it with: @@ -4250,8 +4253,10 @@ def animate_trajectory(self, file_name, start=0, stop=None, time_step=0.1, **kwa rocket = Mesh(file_name).c("green") rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) # Create trail - trail_points = [[self.x(t), self.y(t), self.z(t) - self.env.elevation] - for t in np.arange(start, stop, time_step)] + trail_points = [ + [self.x(t), self.y(t), self.z(t) - self.env.elevation] + for t in np.arange(start, stop, time_step) + ] trail = Line(trail_points, c="k", alpha=0.5) # Setup Plotter plt = Plotter(axes=1, interactive=False) @@ -4260,21 +4265,21 @@ def animate_trajectory(self, file_name, start=0, stop=None, time_step=0.1, **kwa # Animation Loop for t in np.arange(start, stop, time_step): # Calculate rotation angle and vector from quaternions - # Note: This simple rotation logic mimics the old branch. - # Ideally, vedo handles orientation via matrix, but we stick + # Note: This simple rotation logic mimics the old branch. + # Ideally, vedo handles orientation via matrix, but we stick # to the provided logic for now. - + # e0 is the scalar part of the quaternion - angle = np.arccos(2 * self.e0(t)**2 - 1) + 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) - + # update the scene plt.show(world, rocket, trail) @@ -4283,7 +4288,7 @@ def animate_trajectory(self, file_name, start=0, stop=None, time_step=0.1, **kwa while time.time() - start_pause < time_step: plt.render() - if getattr(plt, 'escaped', False): + if getattr(plt, "escaped", False): break plt.interactive().close() @@ -4311,7 +4316,7 @@ def animate_rotate(self, file_name, start=0, stop=None, time_step=0.1, **kwargs) - 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 @@ -4329,13 +4334,13 @@ def animate_rotate(self, file_name, start=0, stop=None, time_step=0.1, **kwargs) "Install it with:\n" " pip install rocketpy[animation]\n" ) from e - + # Enable interaction if needed try: settings.allow_interaction = True except AttributeError: pass # Not available in newer versions of vedo - + if stop is None: stop = self.t_final @@ -4355,9 +4360,9 @@ def animate_rotate(self, file_name, start=0, stop=None, time_step=0.1, **kwargs) plt.show(world, rocket, __doc__, viewup="z", **kwargs) for t in np.arange(start, stop, time_step): - angle = np.arccos(2 * self.e0(t)**2 - 1) + angle = np.arccos(2 * self.e0(t) ** 2 - 1) k = np.sin(angle / 2) if np.sin(angle / 2) != 0 else 1 - + # Keep position static (relative start) to observe only rotation rocket.pos(self.x(start), self.y(start), 0) rocket.rotate_x(self.e1(t) / k) @@ -4366,12 +4371,12 @@ def animate_rotate(self, file_name, start=0, stop=None, time_step=0.1, **kwargs) plt.show(world, rocket) - if getattr(plt, 'escaped', False): + if getattr(plt, "escaped", False): break plt.interactive().close() return None - + def info(self): """Prints out a summary of the data available about the Flight.""" self.prints.all() diff --git a/tests/animation_verification/main.py b/tests/animation_verification/main.py index 5538bd7fe..15c3d42d2 100644 --- a/tests/animation_verification/main.py +++ b/tests/animation_verification/main.py @@ -1,8 +1,10 @@ import os import traceback -from rocketpy import Environment, Flight -from rocket_stl import create_rocket_stl + from rocket_setup import get_calisto_rocket +from rocket_stl import create_rocket_stl + +from rocketpy import Environment, Flight def run_simulation_and_test_animation(): diff --git a/tests/animation_verification/rocket_setup.py b/tests/animation_verification/rocket_setup.py index 4f3c11ba1..26a5640a7 100644 --- a/tests/animation_verification/rocket_setup.py +++ b/tests/animation_verification/rocket_setup.py @@ -1,5 +1,6 @@ import os -from rocketpy import SolidMotor, Rocket + +from rocketpy import Rocket, SolidMotor CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(os.path.dirname(CURRENT_DIR)) diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py index 700285d07..11654cc42 100644 --- a/tests/animation_verification/rocket_stl.py +++ b/tests/animation_verification/rocket_stl.py @@ -1,7 +1,8 @@ import math +import struct + import numpy as np from stl import mesh # Requires numpy-stl package: pip install numpy-stl -import struct def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): From 81604508168ca1f49f5770766e82c3bb5f8cd85e Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sat, 14 Mar 2026 14:38:25 -0300 Subject: [PATCH 12/12] Refactors the PR solving all reviewer's comments - Implemented 3D flight trajectory and attitude animations in the Flight plots layer. - Added methods `animate_trajectory` and `animate_rotate` to visualize rocket flight. - Included validation for animation inputs and error handling for missing STL files. - Updated documentation to reflect new animation features and installation requirements. - Added optional dependency for `vedo` in `requirements-optional.txt`. - Created a default STL model for the rocket. - Removed outdated animation verification tests and replaced them with unit tests for new animation methods. --- CHANGELOG.md | 1 + docs/user/flight.rst | 49 +++- pyproject.toml | 5 +- requirements-optional.txt | 3 +- rocketpy/plots/assets/default_rocket.stl | 16 ++ rocketpy/plots/flight_plots.py | 254 +++++++++++++++++++ rocketpy/simulation/flight.py | 200 --------------- tests/animation_verification/__init__.py | 0 tests/animation_verification/main.py | 78 ------ tests/animation_verification/rocket_setup.py | 96 ------- tests/animation_verification/rocket_stl.py | 236 ----------------- tests/unit/test_plots.py | 200 +++++++++++++++ 12 files changed, 524 insertions(+), 614 deletions(-) create mode 100644 rocketpy/plots/assets/default_rocket.stl delete mode 100644 tests/animation_verification/__init__.py delete mode 100644 tests/animation_verification/main.py delete mode 100644 tests/animation_verification/rocket_setup.py delete mode 100644 tests/animation_verification/rocket_stl.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e46ee3faa..ae43d86d4 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: Air brakes controller functions now support 8-parameter signature [#854](https://github.com/RocketPy-Team/RocketPy/pull/854) - TST: Add acceptance tests for 3DOF flight simulation based on Bella Lui rocket [#914] (https://github.com/RocketPy-Team/RocketPy/pull/914_ - ENH: Add background map auto download functionality to Monte Carlo plots [#896](https://github.com/RocketPy-Team/RocketPy/pull/896) diff --git a/docs/user/flight.rst b/docs/user/flight.rst index 31e7ab588..b005477db 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,51 @@ Flight Data Plots # Flight path and orientation flight.plots.flight_path_angle_data() +3D Flight Animation +~~~~~~~~~~~~~~~~~~~ + +RocketPy can animate the simulated flight trajectory and attitude through the +Flight plots layer. + +.. note:: + + Install optional animation dependencies first: + + .. code-block:: bash + + pip install rocketpy[animation] + +.. code-block:: python + + # Fast start using RocketPy's built-in default STL model + flight.plots.animate_trajectory( + start=0.0, + stop=min(flight.t_final, 20.0), + time_step=0.05, + ) + + # Or provide your own STL model file + flight.plots.animate_trajectory( + file_name="rocket.stl", + start=0.0, + stop=flight.t_final, + time_step=0.05, + azimuth=45, + elevation=20, + ) + + # Keep rocket centered and animate only attitude changes + flight.plots.animate_rotate( + file_name="rocket.stl", + start=0.0, + stop=min(flight.t_final, 20.0), + time_step=0.05, + ) + +Both methods validate the selected time interval and STL path before rendering. +If ``vedo`` is not installed, RocketPy raises an informative ``ImportError`` +with installation instructions. + Forces and Moments ~~~~~~~~~~~~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml index f7e5d1155..01b5d502b 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,7 +64,7 @@ env-analysis = [ ] monte-carlo = [ - "imageio", + "imageio", "multiprocess>=0.70", "statsmodels", "prettytable", 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..86118037f 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,256 @@ 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") + Box = vedo.Box + + return Box( + pos=[center_x, center_y, center_z], + length=length, + width=width, + height=height, + ).wireframe() + + def animate_trajectory( + 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") + + Line = vedo.Line + Mesh = vedo.Mesh + Plotter = vedo.Plotter + settings = vedo.settings + + file_name = self._resolve_animation_model_path(file_name) + stop = self._validate_animation_inputs(file_name, start, stop, time_step) + + try: + settings.allow_interaction = True + except AttributeError: + pass + + world = self._create_animation_box(start, scale=1.2) + base_rocket = Mesh(file_name).c("green") + time_steps = np.arange(start, stop, time_step) + trajectory_points = [] + + plt = 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(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() + return None + + def animate_rotate( + 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") + + Mesh = vedo.Mesh + Plotter = vedo.Plotter + settings = vedo.settings + + file_name = self._resolve_animation_model_path(file_name) + stop = self._validate_animation_inputs(file_name, start, stop, time_step) + + try: + settings.allow_interaction = True + except AttributeError: + pass + + world = self._create_animation_box(start, scale=0.3) + base_rocket = 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 = 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() + return None + def linear_kinematics_data(self, *, filename=None): # pylint: disable=too-many-statements """Prints out all Kinematics graphs available about the Flight diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 21cb37b24..eb82b1998 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1,6 +1,5 @@ # pylint: disable=too-many-lines import math -import time import warnings from copy import deepcopy from functools import cached_property @@ -4143,206 +4142,7 @@ 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 - - # Define the world bounds based on trajectory - max_x = max(self.x[:, 1]) - max_y = max(self.y[:, 1]) - # Use simple logic for bounds - world = Box( - pos=[self.x(start), self.y(start), self.apogee], - length=max_x * 2 if max_x != 0 else 1000, - width=max_y * 2 if max_y != 0 else 1000, - height=self.apogee, - ).wireframe() - - # Load rocket mesh - rocket = Mesh(file_name).c("green") - rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) - # Create trail - trail_points = [ - [self.x(t), self.y(t), self.z(t) - self.env.elevation] - for t in np.arange(start, stop, time_step) - ] - trail = Line(trail_points, c="k", alpha=0.5) - # Setup Plotter - plt = Plotter(axes=1, interactive=False) - plt.show(world, rocket, __doc__, viewup="z", **kwargs) - - # Animation Loop - for t in np.arange(start, stop, time_step): - # Calculate rotation angle and vector from quaternions - # Note: This simple rotation logic mimics the old branch. - # Ideally, vedo handles orientation via matrix, but we stick - # to the provided logic for now. - - # e0 is the scalar part of the quaternion - 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) - - - # update the scene - plt.show(world, rocket, trail) - - # slow down to make animation visible - start_pause = time.time() - while time.time() - start_pause < time_step: - plt.render() - - if getattr(plt, 'escaped', False): - break - - plt.interactive().close() - return None - - def animate_rotate(self, file_name, start=0, stop=None, time_step=0.1, **kwargs): - """ - Animation of rocket attitude (rotation) during the flight. - - Parameters - ---------- - file_name : str - 3D object file representing your rocket, usually in .stl format. - 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. 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. - """ - try: - from vedo import Box, 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" - ) from e - - - # Enable interaction if needed - try: - settings.allow_interaction = True - except AttributeError: - pass # Not available in newer versions of vedo - - - if stop is None: - stop = self.t_final - - # Smaller box for rotation view - 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, - height=self.apogee * 0.1, - ).wireframe() - - rocket = Mesh(file_name).c("green") - # Initialize at origin relative to view - rocket.pos(self.x(start), self.y(start), 0).add_trail(n=len(self.x[:, 1])) - - plt = Plotter(axes=1, interactive=False) - plt.show(world, rocket, __doc__, viewup="z", **kwargs) - - for t in np.arange(start, stop, time_step): - angle = np.arccos(2 * self.e0(t)**2 - 1) - k = np.sin(angle / 2) if np.sin(angle / 2) != 0 else 1 - - # Keep position static (relative start) to observe only rotation - rocket.pos(self.x(start), self.y(start), 0) - rocket.rotate_x(self.e1(t) / k) - rocket.rotate_y(self.e2(t) / k) - rocket.rotate_z(self.e3(t) / k) - - plt.show(world, rocket) - - if getattr(plt, 'escaped', False): - break - plt.interactive().close() - return None def info(self): """Prints out a summary of the data available about the Flight.""" self.prints.all() diff --git a/tests/animation_verification/__init__.py b/tests/animation_verification/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/animation_verification/main.py b/tests/animation_verification/main.py deleted file mode 100644 index 04a61a0f0..000000000 --- a/tests/animation_verification/main.py +++ /dev/null @@ -1,78 +0,0 @@ -import os -import traceback - -from rocket_setup import get_calisto_rocket -from rocket_stl import create_rocket_stl - -from rocketpy import Environment, Flight -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() diff --git a/tests/animation_verification/rocket_setup.py b/tests/animation_verification/rocket_setup.py deleted file mode 100644 index b302de338..000000000 --- a/tests/animation_verification/rocket_setup.py +++ /dev/null @@ -1,96 +0,0 @@ -import os - -from rocketpy import Rocket, SolidMotor -from rocketpy import SolidMotor, Rocket - -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT_DIR = os.path.dirname(os.path.dirname(CURRENT_DIR)) -DATA_DIR = os.path.join(ROOT_DIR, "data") - -OFF_DRAG_PATH = os.path.join(DATA_DIR, "rockets/calisto/powerOffDragCurve.csv") -ON_DRAG_PATH = os.path.join(DATA_DIR, "rockets/calisto/powerOnDragCurve.csv") -AIRFOIL_PATH = os.path.join(DATA_DIR, "airfoils/NACA0012-radians.txt") -MOTOR_PATH = os.path.join(DATA_DIR, "motors/cesaroni/Cesaroni_M1670.eng") - - -def get_motor(): - """Locates the motor file and returns a configured SolidMotor object.""" - # We can now point directly to the file without searching - if not os.path.exists(MOTOR_PATH): - raise FileNotFoundError(f"Could not find Cesaroni_M1670.eng at: {MOTOR_PATH}") - - return SolidMotor( - thrust_source=MOTOR_PATH, - dry_mass=1.815, - dry_inertia=(0.125, 0.125, 0.002), - nozzle_radius=33 / 1000, - grain_number=5, - grain_density=1815, - grain_outer_radius=33 / 1000, - grain_initial_inner_radius=15 / 1000, - grain_initial_height=120 / 1000, - grain_separation=5 / 1000, - grains_center_of_mass_position=0.397, - center_of_dry_mass_position=0.317, - nozzle_position=0, - burn_time=3.9, - throat_radius=11 / 1000, - coordinate_system_orientation="nozzle_to_combustion_chamber", - ) - - -def get_calisto_rocket(): - """Configures and returns the Calisto Rocket object.""" - motor = get_motor() - - calisto = Rocket( - radius=127 / 2000, - mass=14.426, - inertia=(6.321, 6.321, 0.034), - power_off_drag=OFF_DRAG_PATH, - power_on_drag=ON_DRAG_PATH, - center_of_mass_without_motor=0, - coordinate_system_orientation="tail_to_nose", - ) - - calisto.add_motor(motor, position=-1.255) - calisto.set_rail_buttons( - upper_button_position=0.0818, - lower_button_position=-0.618, - angular_position=45, - ) - - # Aerodynamic surfaces - calisto.add_nose(length=0.55829, kind="vonKarman", position=1.27) - calisto.add_trapezoidal_fins( - n=4, - root_chord=0.120, - tip_chord=0.060, - span=0.110, - position=-1.04956, - cant_angle=0, - airfoil=(AIRFOIL_PATH, "radians"), - ) - calisto.add_tail( - top_radius=0.0635, bottom_radius=0.0435, length=0.060, position=-1.194656 - ) - - # Parachutes - calisto.add_parachute( - name="Main", - cd_s=10.0, - trigger=800, - sampling_rate=105, - lag=1.5, - noise=(0, 8.3, 0.5), - ) - calisto.add_parachute( - name="Drogue", - cd_s=1.0, - trigger="apogee", - sampling_rate=105, - lag=1.5, - noise=(0, 8.3, 0.5), - ) - - return calisto diff --git a/tests/animation_verification/rocket_stl.py b/tests/animation_verification/rocket_stl.py deleted file mode 100644 index dac230287..000000000 --- a/tests/animation_verification/rocket_stl.py +++ /dev/null @@ -1,236 +0,0 @@ -import math -import struct - -import numpy as np -from stl import mesh # Requires numpy-stl package: pip install numpy-stl -import numpy as np -from stl import mesh # Requires numpy-stl package: pip install numpy-stl -import struct - - -def create_rocket_stl(filename="rocket_model.stl", length=100, radius=10): - """ - Creates a detailed rocket-shaped STL file with proper scale. - - Parameters - ---------- - filename : str - Output filename - length : float - Rocket length in meters - radius : float - Rocket base radius in meters - """ - - # More realistic proportions - nose_length = length * 0.25 - engine_length = length * 0.1 - - # Fin parameters - fin_height = radius * 3 - fin_width = radius * 2 - fin_thickness = radius * 0.2 - num_fins = 4 - - # Calculate number of vertices for smooth curves - num_segments = 36 - vertices = [] - faces = [] - - # Helper function to add triangle - def add_triangle(v1, v2, v3): - nonlocal faces - idx = len(vertices) - vertices.extend([v1, v2, v3]) - faces.append([idx, idx + 1, idx + 2]) - - # Helper function to add quadrilateral as two triangles - def add_quad(v1, v2, v3, v4): - add_triangle(v1, v2, v3) - add_triangle(v1, v3, v4) - - # 1. Create nose cone (pointed ogive) - nose_vertices = [] - for i in range(num_segments + 1): - angle = 2 * math.pi * i / num_segments - x = math.cos(angle) * radius - y = math.sin(angle) * radius - z = length # Tip at full length - nose_vertices.append([x, y, z]) - - # Create nose cone triangles - nose_tip = [0, 0, length] - for i in range(num_segments): - v1 = nose_tip - v2 = nose_vertices[i] - v3 = nose_vertices[(i + 1) % num_segments] - add_triangle(v1, v2, v3) - - # 2. Create main body cylinder - body_z_start = length - nose_length - body_z_end = engine_length - - # Create vertices for top and bottom circles of body - top_circle = [] - bottom_circle = [] - - for i in range(num_segments): - angle = 2 * math.pi * i / num_segments - x = math.cos(angle) * radius - y = math.sin(angle) * radius - - top_circle.append([x, y, body_z_start]) - bottom_circle.append([x, y, body_z_end]) - - # Create body cylinder triangles - for i in range(num_segments): - next_i = (i + 1) % num_segments - - # Side quad - v1 = top_circle[i] - v2 = top_circle[next_i] - v3 = bottom_circle[next_i] - v4 = bottom_circle[i] - add_quad(v1, v2, v3, v4) - - # Top circle (connecting to nose) - v1 = top_circle[i] - v2 = top_circle[next_i] - v3 = nose_vertices[i] - add_triangle(v1, v2, v3) - - # 3. Create engine nozzle (truncated cone) - engine_radius = radius * 0.7 - engine_z_end = 0 - - # Create vertices for engine circles - engine_top_circle = [] - engine_bottom_circle = [] - - for i in range(num_segments): - angle = 2 * math.pi * i / num_segments - x_top = math.cos(angle) * radius - y_top = math.sin(angle) * radius - x_bottom = math.cos(angle) * engine_radius - y_bottom = math.sin(angle) * engine_radius - - engine_top_circle.append([x_top, y_top, body_z_end]) - engine_bottom_circle.append([x_bottom, y_bottom, engine_z_end]) - - # Create engine nozzle triangles - for i in range(num_segments): - next_i = (i + 1) % num_segments - - # Side quad - v1 = engine_top_circle[i] - v2 = engine_top_circle[next_i] - v3 = engine_bottom_circle[next_i] - v4 = engine_bottom_circle[i] - add_quad(v1, v2, v3, v4) - - # Connect to body - v1 = engine_top_circle[i] - v2 = engine_top_circle[next_i] - v3 = bottom_circle[i] - add_triangle(v1, v2, v3) - - # 4. Create fins - for fin_num in range(num_fins): - fin_angle = 2 * math.pi * fin_num / num_fins - - # Inner fin edge (attached to rocket) - inner_x = math.cos(fin_angle) * radius - inner_y = math.sin(fin_angle) * radius - inner_top = [inner_x, inner_y, body_z_end + fin_height * 0.3] - inner_bottom = [inner_x, inner_y, body_z_end - fin_height * 0.7] - - # Outer fin edge - outer_x = math.cos(fin_angle) * (radius + fin_width) - outer_y = math.sin(fin_angle) * (radius + fin_width) - outer_top = [outer_x, outer_y, body_z_end + fin_height * 0.3] - outer_bottom = [outer_x, outer_y, body_z_end - fin_height * 0.7] - - # Fin side that attaches to rocket - add_quad( - inner_top, - inner_bottom, - bottom_circle[fin_num * num_segments // num_fins], - bottom_circle[(fin_num * num_segments // num_fins + 1) % num_segments], - ) - - # Fin surfaces - # Front face - add_quad(inner_top, outer_top, outer_bottom, inner_bottom) - - # Top face - add_triangle( - inner_top, - outer_top, - [ - inner_top[0] + math.cos(fin_angle + math.pi / 2) * fin_thickness * 0.5, - inner_top[1] + math.sin(fin_angle + math.pi / 2) * fin_thickness * 0.5, - inner_top[2], - ], - ) - - # Bottom face - add_triangle( - inner_bottom, - outer_bottom, - [ - inner_bottom[0] - + math.cos(fin_angle + math.pi / 2) * fin_thickness * 0.5, - inner_bottom[1] - + math.sin(fin_angle + math.pi / 2) * fin_thickness * 0.5, - inner_bottom[2], - ], - ) - - # 5. Create bottom cap - center_bottom = [0, 0, engine_z_end] - for i in range(num_segments): - next_i = (i + 1) % num_segments - add_triangle( - center_bottom, engine_bottom_circle[i], engine_bottom_circle[next_i] - ) - - # Convert to numpy arrays - vertices_array = np.array(vertices) - faces_array = np.array(faces) - - # Create the mesh - rocket_mesh = mesh.Mesh(np.zeros(faces_array.shape[0], dtype=mesh.Mesh.dtype)) - for i, face in enumerate(faces_array): - for j in range(3): - rocket_mesh.vectors[i][j] = vertices_array[face[j]] - - # Write the mesh to file - rocket_mesh.save(filename) - print(f"Rocket STL saved to {filename}") - print(f"Total triangles: {len(faces)}") - - -# Alternative version using pure Python STL generation -def create_rocket_stl_simple(filename="rocket_simple.stl", length=100, radius=10): - """Simpler version using pure Python without numpy-stl dependency""" - - def write_stl(faces, filename): - with open(filename, "wb") as f: - # Write 80 byte header - f.write(b"\x00" * 80) - # Write number of faces - f.write(struct.pack("