Module projectile_sim

This module simulates projectile motion using VPython for visualization. It provides a ProjectileSimulator class that calculates and displays the trajectory of a projectile in different environments and for different projectile types.

Functions

def main() ‑> None

Main entry point for the Projectile Simulator.

This function parses command-line arguments to determine whether to use predefined test parameters or prompt the user for input. It initializes the simulator with the appropriate parameters and runs the simulation. If the --no_gui flag is set, the simulation runs without a graphical user interface (GUI).

  • Command-line Arguments:
    --test: Run the simulation with predefined test parameters.
    --no_gui: Run the simulation without the GUI.

  • Prompts:

    • If not using predefined test parameters, the user is prompted to enter:
      • The Environment (choose from canned Environments, or Custom)
        • If a Custom Environment is chosen, enter the gravity (m/s²) and air density (kg/m³)
      • The Projectile (choose from canned Projectiles, or Custom)
        • If a Custom Projectile is chosen, enter the mass (kg) and radius (m)
      • The initial speed of the projectile (m/s)
      • The angle of launch (degrees)
  • What it does:

    • Runs the simulation, optionally displaying it in a VPython GUI window.
    • Waits for a key press to exit if the GUI is enabled.

Classes

class CurveType (*args, **kwds)

Enum to hold the different type of curves.

Expand source code
class CurveType(IntEnum):
    """
    Enum to hold the different type of curves.
    """
    ACTUAL = 0
    """Curve of actual data with all parameters applied"""
    NO_AIR = 1
    """Curve of data with no air resistance"""
    NO_AIR_45 = 2
    """Curve of ideal curve at 45° and no air resistance"""
    NO_AIR_90 = 3
    """Curve of ideal curve at 90° and no air resistance"""

Ancestors

  • enum.IntEnum
  • builtins.int
  • enum.ReprEnum
  • enum.Enum

Class variables

var ACTUAL

Curve of actual data with all parameters applied

var NO_AIR

Curve of data with no air resistance

var NO_AIR_45

Curve of ideal curve at 45° and no air resistance

var NO_AIR_90

Curve of ideal curve at 90° and no air resistance

class Graph (graph: vpython.vpython.graph, curves: List[vpython.vpython.gcurve])

Data class to hold Graph information. Every graph has a list of curves.

Expand source code
@dataclass
class Graph:
    """
    Data class to hold Graph information.  Every graph has a list of curves.
    """
    graph: vp.graph
    curves: List[vp.gcurve]

Class variables

var curves : List[vpython.vpython.gcurve]
var graph : vpython.vpython.graph
class GraphType (*args, **kwds)

Enum to hold the different type of graphs.

Expand source code
class GraphType(IntEnum):
    """
    Enum to hold the different type of graphs.
    """
    DIST_HEIGHT = 0
    """Distance vs. Height Graph"""
    TIME_HEIGHT = 1
    """Time vs. Height Graph"""
    TIME_DIST = 2
    """Time vs. Distance Graph"""

Ancestors

  • enum.IntEnum
  • builtins.int
  • enum.ReprEnum
  • enum.Enum

Class variables

var DIST_HEIGHT

Distance vs. Height Graph

var TIME_DIST

Time vs. Distance Graph

var TIME_HEIGHT

Time vs. Height Graph

class ProjectileSimulator (environment: Environment, projectile: Projectile, speed: float, angle: float)

A class to simulate and visualize projectile motion.

Attributes

environment : Environment
The type of environment for simulation.
projectile : Projectile
The type of projectile for simulation.
speed : float
The initial speed of the projectile (m/s).
angle : float
The launch angle (degrees).
angle_rad : float
The launch angle (radians).
v0x : float
The velocity component in the x direction (m)
v0y : float
The velocity component in the y direction (m)
time_to_max_height : float
The time to reach the maximum height (s)
total_flight_time : float
The total flight time (s)
total_distance : float
The total distance traveled (m)
max_height : float
The maximum height achieved (m)
dist_at_max_height : float
The x distance traveled when at the highest point (m)
max_possible_dist : float
The max possible distance traveled at speed given (launched at 45°) (m)
max_possible_height : float
The max possible height reached at speed given (launched at 90°) (m)
max_possible_flight_time : float
The max possible flight time at speed given (launched at 90°) (s)

Args

environment : Environment
The type of environment for simulation.
projectile : Projectile
The type of projectile for simulation.
speed : float
The initial speed of the projectile.
angle : float
The angle of launch.

Raises

ValueError
If the launch angle is not > 0° and <= 90°.
Expand source code
class ProjectileSimulator:
    """
    A class to simulate and visualize projectile motion.

    Attributes:
        environment (Environment): The type of environment for simulation.
        projectile (Projectile): The type of projectile for simulation.
        speed (float): The initial speed of the projectile (m/s).
        angle (float): The launch angle (degrees).
        angle_rad (float): The launch angle (radians).
        v0x (float): The velocity component in the x direction (m)
        v0y (float): The velocity component in the y direction (m)
        time_to_max_height (float): The time to reach the maximum height (s)
        total_flight_time (float): The total flight time (s)
        total_distance (float): The total distance traveled (m)
        max_height (float): The maximum height achieved (m)
        dist_at_max_height (float): The x distance traveled when at the highest point (m)
        max_possible_dist (float): The max possible distance traveled at speed given
                                   (launched at 45°) (m)
        max_possible_height (float): The max possible height reached at speed given
                                     (launched at 90°) (m)
        max_possible_flight_time (float): The max possible flight time at speed given
                                          (launched at 90°) (s)
    """

    # Flag to indicate whether the GUI should be disabled (True = no GUI)
    _no_gui: bool = False

    def __init__(self,
                 environment: Environment,
                 projectile: Projectile,
                 speed: float,
                 angle: float):
        """
        Args:
            environment (Environment): The type of environment for simulation.
            projectile (Projectile): The type of projectile for simulation.
            speed (float): The initial speed of the projectile.
            angle (float): The angle of launch.

        Raises:
            ValueError: If the launch angle is not > 0° and <= 90°.
        """
        self.environment: Environment = environment
        self.projectile: Projectile = projectile
        self.speed: float = speed
        self.angle: float = angle

        if self.angle <= 0 or self.angle > 90:
            raise ValueError("Launch angle must be > 0° and <= 90°.")

        # Convert angle to radians for trigonometric calculations
        self.angle_rad: float = math.radians(self.angle)

        # Calculate initial velocity components
        self.v0x: float = self.speed * math.cos(self.angle_rad)
        self.v0y: float = self.speed * math.sin(self.angle_rad)

        # Key trajectory parameters
        self.time_to_max_height: float = 0
        self.total_flight_time: float = 0
        self.total_distance: float = 0
        self.max_height: float = 0
        self.dist_at_max_height: float = 0

        # Calculate maximum possible distance, height, and flight time (used for graph scaling)
        self.max_possible_dist: float = (self.speed**2) / self.environment.gravity
        self.max_possible_height: float = (self.speed**2) / (2 * self.environment.gravity)
        self.max_possible_flight_time: float = 2 * self.speed / self.environment.gravity


        ### Initialize visualization attributes  ###

        # Canvas for text
        self._canvas: Optional[vp.canvas] = None

        # We will have 3 graphs (if GUI enabled):
        #   Distance (x) vs. Height (y)
        #   Time (x) vs. Height (y)
        #   Time (x) vs. Distance (y)
        # We will have 4 types of curves on each graph (if GUI enabled):
        #   Actual data
        #   Data at given angle with no air resistance
        #   Data at 45° with no air resistance
        #   Data at 90° with no air resistance
        self._graphs: List[Graph] = []

        # Labels for canvas
        self._labels: Dict[str, vp.label] = {}

    def __del__(self) -> None:
        """
        Clean up VPython objects when the simulator is deleted.
        """
        try:
            if self._canvas:
                self._canvas.delete()
                self._canvas = None

            if self._graphs:
                for graph in self._graphs:
                    graph.graph.delete()
        except Exception:
            pass

    @staticmethod
    def quit_simulation() -> None:
        """Stop the VPython server."""
        if ProjectileSimulator._no_gui is False:
            # We don't import vp_services until needed, because importing it will start
            # the server, if not started already.
            import vpython.no_notebook as vp_services  # type: ignore[import-untyped]
            vp_services.stop_server()

    @classmethod
    def disable_gui(cls, no_gui: bool) -> None:
        """
        Enables or disables the GUI.

        Args:
            no_gui (bool): Flag to indicate where GUI should be disabled (True = disable GUI).
        """
        cls._no_gui = no_gui

    def _setup_canvas(self) -> None:
        """Set up the VPython canvas for 3D visualization."""
        self._canvas = vp.canvas(width=400, height=400, align='left')

        # Create blank label so this canvas will show first
        vp.label(text='', box=False)

        # Range of canvas labels will be -10 to 10
        self._canvas.range = 10

    def _create_graph_spacer(self) -> None:
        """
        Create an invisible/empty graph for spacing (VPython doesn't
        separate the graphs decently by itself, so this is a workaround).
        """
        spacing_graph = vp.graph(width=1600,
                                 height=20,
                                 align='right',
                                 foreground=vp.color.gray(0.95),
                                 background=vp.color.gray(0.95))
        
        # Has to have a curve
        spacing_curve = vp.gcurve(graph=spacing_graph)

        # Plot a point so graph shows up
        spacing_curve.plot(0,0)

    def _create_canvas_spacer(self) -> None:
        """
        Create blank canvas for spacer between canvas and 1st graph (VPython workaround)
        """
        vp.canvas(width=20, height=400, align='left', background=vp.color.gray(0.95))

        # We need to create a label for canvas to show up
        vp.label(text='', box=False)

        # Reselect the "real" canvas, so this one won't be used
        # (by default VPyhon uses the last canvas created)
        assert self._canvas
        self._canvas.select()

    def _add_graph_legend(self, curves: List[vp.gcurve]) -> None:
        """
        Adds a legend to the specified curves.

        Args:
            curves (List[vp.gcurve]): A list of curves to add a legend to.
        """
        curves[CurveType.ACTUAL].label = f'{self.angle:.1f}°, Actual'
        curves[CurveType.NO_AIR].label = f'{self.angle:.1f}°, No air'
        curves[CurveType.NO_AIR_45].label = '45°, No air'
        curves[CurveType.NO_AIR_90].label = '90°, No air'

    def _setup_graph(self) -> None:
        """Set up the VPython graph for trajectory plotting."""
        # Scaling factor to give some margins to x and y axis
        scale_factor: float = 1.05
        graph_width: int = 800
        graph_height: int = 400

        # Create a space between the canvas and the 1st graph
        self._create_canvas_spacer()

        # Setup main graph
        main_graph = vp.graph(
            title='<i>Projectile Motion Simulator</i>\n' +
                  f'Initial Speed: {self.speed:.1f} m/s,  Launch Angle: {self.angle:.1f}°\n' +
                  f'Environment: {str(self.environment.type)} ' +
                  f'(Gravity: {self.environment.gravity:.3f} m/s²,  ' +
                  f'Air Density: {self.environment.air_density:.3f} kg/m³)\n' +
                  f'Projectile: {str(self.projectile.type)} ' +
                  f'(Mass: {self.projectile.mass:.3f} kg,  ' +
                  f'Surface Area: {self.projectile.area:.3f} m²)',
            xtitle='Distance (m)', ytitle='Height (m)',
            xmin=0, xmax=self.max_possible_dist * scale_factor,
            ymin=0, ymax=self.max_possible_height * scale_factor,
            width=graph_width, height=graph_height,
            align='left'
        )

        # The size of the dot at the end of the curves
        dot_radius: int = 3

        # Create list of main graph curves
        main_curves: List[vp.gcurve] = []

        # Curve for data plot
        main_curves.append(vp.gcurve(color=vp.color.red, dot=True, dot_radius=dot_radius, dot_color=vp.color.red))

        # Plot a point to make it the first graph (first graph to plot a point becomes the first graph in VPython)
        main_curves[CurveType.ACTUAL].plot(0,0)

        # Ideal curve at given angle, no air
        main_curves.append(vp.gcurve(color=vp.color.orange, dot=True, dot_radius=dot_radius, dot_color=vp.color.orange))

        # Ideal curve at 45°, no air
        main_curves.append(vp.gcurve(color=vp.color.blue, dot=True, dot_radius=dot_radius, dot_color=vp.color.blue))

        # Ideal curve at 90°, no air
        main_curves.append(vp.gcurve(color=vp.color.magenta, dot=True, dot_radius=dot_radius, dot_color=vp.color.magenta))

        # Add legend text
        self._add_graph_legend(main_curves)

        # Add Graph and curves to Distance vs. Height graph
        self._graphs.append(Graph(main_graph, main_curves))

        # Insert a graph spacer
        self._create_graph_spacer()

        # Create list of Time vs. Height graph curves
        th_curves: List[vp.gcurve] = []

        # Time vs. Height graph
        th_graph = vp.graph(
            title='Time vs. Height',
            xtitle='Time (s)', ytitle='Height (m)',
            width=graph_width, height=graph_height,
            align='left',
            xmin=0, xmax=self.max_possible_flight_time * scale_factor,
            ymin=0, ymax=self.max_possible_height * scale_factor,
        )
        th_curves.append(vp.gcurve(color=vp.color.red, dot=True, dot_radius=dot_radius, dot_color=vp.color.red))
        th_curves.append(vp.gcurve(color=vp.color.orange, dot=True, dot_radius=dot_radius, dot_color=vp.color.orange))
        th_curves.append(vp.gcurve(color=vp.color.blue, dot=True, dot_radius=dot_radius, dot_color=vp.color.blue))
        th_curves.append(vp.gcurve(color=vp.color.magenta, dot=True, dot_radius=dot_radius, dot_color=vp.color.magenta))

        # Add legend text
        self._add_graph_legend(th_curves)

        # Add Graph and curves to Time vs. Height graph
        self._graphs.append(Graph(th_graph, th_curves))

        # Create list of Time vs. Distance graph curves
        td_curves: List[vp.gcurve] = []

        # Time vs. Distance graph
        td_graph = vp.graph(
            title='Time vs. Distance',
            xtitle='Time (s)', ytitle='Distance (m)',
            width=graph_width, height=graph_height,
            align='right',
            xmin=0, xmax=self.max_possible_flight_time * scale_factor,
            ymin=0, ymax=self.max_possible_dist * scale_factor,
        )
        td_curves.append(vp.gcurve(color=vp.color.red, dot=True, dot_radius=dot_radius, dot_color=vp.color.red))
        td_curves.append(vp.gcurve(color=vp.color.orange, dot=True, dot_radius=dot_radius, dot_color=vp.color.orange))
        td_curves.append(vp.gcurve(color=vp.color.blue, dot=True, dot_radius=dot_radius, dot_color=vp.color.blue))
        td_curves.append(vp.gcurve(color=vp.color.magenta, dot=True, dot_radius=dot_radius, dot_color=vp.color.magenta))

        # Add legend text
        self._add_graph_legend(td_curves)

        # Add Graph and curves to Time vs. Distance graph
        self._graphs.append(Graph(td_graph, td_curves))

    def _create_labels(self) -> None:
        """Create labels for displaying simulation information."""
        assert self._canvas

        # Start labels 2 over from left margin (range -10 to 10)
        left_margin: int = -self._canvas.range + 1

        # Start lines at line number 5  (range 10 to -10)
        line_number: int = 5

        ### Create labels for various trajectory parameters  ###
        self._labels['max_possible_dist'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                                     text=f'Max Possible Distance (no air) @ 45°: {
                                                     self.max_possible_dist:.3f} m',
                                                     height=16, align='left', box=False)

        line_number -= 1

        self._labels['max_possible_height_45'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                                          text=f'Max Possible Height (no air) @ 45°: {
                                                          self.max_possible_height/2:.3f} m',
                                                          height=16, align='left', box=False)

        line_number -= 1

        self._labels['max_possible_height_90'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                                          text=f'Max Possible Height (no air) @ 90°: {
                                                          self.max_possible_height:.3f} m',
                                                          height=16, align='left', box=False)

        line_number -= 1

        self._labels['max_possible_flight_time_45'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                                               text=f'Max Possible Flight Time (no air) @ 45°: {
                                                               self.max_possible_flight_time/math.sqrt(2):.3f} s',
                                                               height=16, align='left', box=False)

        line_number -= 1

        self._labels['max_possible_flight_time_90'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                                               text=f'Max Possible Flight Time (no air) @ 90°: {
                                                               self.max_possible_flight_time:.3f} s',
                                                               height=16, align='left', box=False)

        line_number -= 2

        self._labels['height'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                          text='', height=16, align='left', box=False)

        line_number -= 1

        self._labels['time_to_max_height'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                                      text='', height=16, align='left', box=False)

        line_number -= 1

        self._labels['dist_at_max_height'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                                      text='', height=16, align='left', box=False)

        line_number -= 2

        self._labels['flight_time'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                               text='', height=16, align='left', box=False)

        line_number -= 1

        self._labels['total_dist'] = vp.label(pos=vp.vector(left_margin, line_number, 0),
                                              text='', height=16, align='left', box=False)

    def _update_label(self, key: str, text: str) -> None:
        """
        Updates given label's text, if GUI is enabled

        Args:
            key (str): The key for the label dict entry.
            text (str): The str to update the label with
        """
        if ProjectileSimulator._no_gui is False:
            self._labels[key].text = text

    def _calculate_dt_and_rate(self,
                               min_steps: int = 100,
                               max_steps: int = 1000,
                               target_fps: int = 500) -> Tuple[float, int]:
        """
        Calculate the time step and frame rate for smooth simulation.

        Args:
            min_steps (int): Minimum number of simulation steps.
            max_steps (int): Maximum number of simulation steps.
            target_fps (int): Target frames per second for visualization.

        Returns:
            Tuple[float, int]: The time step (dt) and frame rate (rate).
        """
        steps: int = min(max(min_steps, int(self.max_possible_flight_time * target_fps)), max_steps)
        dt: float = self.max_possible_flight_time / steps
        rate: int = min(int(1 / dt), target_fps)
        return dt, rate

    def _acceleration(self, vx: float, vy: float) -> Tuple[float, float]:
        """
        Calculates the acceleration in the x and y direction.

        Args:
            vx (float): x velocity
            vy (float): y velocity

        Returns:
            Tuple[float, float]: The x-y components of acceleration.
        """
        # Calculate speed
        speed = math.sqrt(vx**2 + vy**2)

        # Calculate drag force
        drag_force = 0.5 * self.environment.air_density * speed**2 * DRAG_COEFFICIENT_SPHERE * self.projectile.area

        # Calculate drag acceleration
        if self.projectile.mass > 0:
            drag_acc = drag_force / self.projectile.mass
        else:
            drag_acc = 0

        # Calculate x and y components of acceleration
        ax = -drag_acc * vx / speed
        ay = -self.environment.gravity - (drag_acc * vy / speed)

        return ax, ay

    def _velocity_verlet_update(self, traj: Trajectory, dt: float) -> None:
        """
        Uses the Velocity Verlet algorithm to update the x,y positions and velocities.

        Args:
            traj (Trajectory): The current trajectory of the object (x, y, vx, vy)
            dt (float): Time delta since last update
        """
        # Half-step velocity update
        ax, ay = self._acceleration(traj.vel.x, traj.vel.y)
        vx_half = traj.vel.x + 0.5 * ax * dt
        vy_half = traj.vel.y + 0.5 * ay * dt

        # Full position update
        traj.pos.x = traj.pos.x + vx_half * dt
        traj.pos.y = traj.pos.y + vy_half * dt

        # Recalculate acceleration at new position
        ax_new, ay_new = self._acceleration(vx_half, vy_half)

        # Full velocity update
        traj.vel.x = traj.vel.x + 0.5 * (ax + ax_new) * dt
        traj.vel.y = traj.vel.y + 0.5 * (ay + ay_new) * dt

    def _plot_points(self, curve: Optional[vp.gcurve], x: float, y: float) -> None:
        """
        Plots the x and y points on the curve, if GUI is active

        Args:
            x (float): x coordinate
            y (float): y coordinate
        """
        if curve:
            curve.plot(x, y)

    def _plot_point_on_each_graph(self, curve: CurveType, pos: vp.vector, t: float) -> None:
        """
        Plots the point on the curve specified on all graphs.

        Args:
            curve (CurveType): The curve to plot the data on
            pos (vp.vector): x-y position
            t (float): time
        """
        self._plot_points(self._graphs[GraphType.DIST_HEIGHT].curves[curve], pos.x, pos.y)
        self._plot_points(self._graphs[GraphType.TIME_HEIGHT].curves[curve], t, pos.y)
        self._plot_points(self._graphs[GraphType.TIME_DIST].curves[curve], t, pos.x)

    def _plot_and_update_ideals(self, trajectories: List[Trajectory], t: float) -> None:
        """
        Plots the x, y points on all the graphs for all the ideal curves (no air resistance), then updates the
        ideal x,y positions.

        Args:
            trajectories (List[Trajectory]):  The trajectory data for all the ideal curves
            t (float): time
        """
        for i, _ in enumerate(self._graphs, 1):
            if trajectories[i].pos.y >= 0:
                # Plot points
                self._plot_point_on_each_graph(CurveType(i), trajectories[i].pos, t)

                # Update x and y
                trajectories[i].pos.x = trajectories[i].vel.x * t
                trajectories[i].pos.y = (trajectories[i].vel.y * t) - (0.5 * self.environment.gravity * t**2)

    def run_simulation(self) -> None:
        """
        Run the projectile motion simulation and visualize the results.

        This method sets up the visualization, calculates the projectile's
        trajectory, and updates the display in real-time.
        """
        if ProjectileSimulator._no_gui is False:
            self._setup_canvas()
            self._setup_graph()
            self._create_labels()

        t: float = 0
        dt: float
        rate: float
        dt, rate = self._calculate_dt_and_rate()
        pos_prev: vp.vector = vp.vector(0, 0, 0)
        max_height_reached: bool = False
        speed_at_45: float = self.speed / math.sqrt(2)

        ## Set initial trajectories for each curve type ##
        trajectories: List[Trajectory] = []

        # Both actual and "actual with no air" start with the initial velocity given (broken into their x-y components)
        trajectories.append(Trajectory(pos=vp.vector(0,0,0), vel=vp.vector(self.v0x, self.v0y, 0)))
        trajectories.append(Trajectory(pos=vp.vector(0,0,0), vel=vp.vector(self.v0x, self.v0y, 0)))

        # At 45°, both x and y velocities are the same
        trajectories.append(Trajectory(pos=vp.vector(0,0,0), vel=vp.vector(speed_at_45, speed_at_45, 0)))

        # At 90°, x velocity component is 0, and y is the full speed given
        trajectories.append(Trajectory(pos=vp.vector(0,0,0), vel=vp.vector(0, self.speed, 0)))

        # While y is above the x-axis for the actual curve
        while trajectories[CurveType.ACTUAL].pos.y >= 0:
            if ProjectileSimulator._no_gui is False:
                vp.rate(rate)

                # Plot all the ideal values on all the graphs
                self._plot_and_update_ideals(trajectories, t)

                # Plot actual x,y position on all the graphs
                self._plot_point_on_each_graph(CurveType.ACTUAL, trajectories[CurveType.ACTUAL].pos, t)

                # Update flight time label
                self._update_label('flight_time', f'Flight Time: {t:.3f} s')

            # Check if max height is reached and update labels accordingly
            if max_height_reached is False:
                if trajectories[CurveType.ACTUAL].pos.y >= pos_prev.y:
                    self._update_label('height', f'Height: {trajectories[CurveType.ACTUAL].pos.y:.3f} m')
                else:
                    max_height_reached = True
                    self.max_height = pos_prev.y
                    self.time_to_max_height = t - dt
                    self.dist_at_max_height = pos_prev.x
                    self._update_label('height', f'Max Height: {self.max_height:.3f} m')
                    self._update_label('time_to_max_height', f'Time to Max Height: {self.time_to_max_height:.3f} s')
                    self._update_label('dist_at_max_height', f'Distance at Max Height: {self.dist_at_max_height:.3f} m')

            # Store current x and y
            pos_prev = copy(trajectories[CurveType.ACTUAL].pos)

            # Update actual x,y position and velocity
            self._velocity_verlet_update(trajectories[CurveType.ACTUAL], dt)

            # Update time
            t += dt

        # Set final values to last know values before y crossed x-axis
        self.total_distance = pos_prev.x
        self.total_flight_time = t - dt

        # If GUI active
        if ProjectileSimulator._no_gui is False:
            # Update final labels
            self._update_label('flight_time', f'Total Flight Time: {self.total_flight_time:.3f} s')
            self._update_label('total_dist', f'Total Distance: {self.total_distance:.3f} m')

            # Continue to complete ideal curve plots if they are in progress still
            while trajectories[CurveType.NO_AIR].pos.y >= 0 or \
                  trajectories[CurveType.NO_AIR_45].pos.y >= 0 or \
                  trajectories[CurveType.NO_AIR_90].pos.y >= 0:

                vp.rate(rate)

                # Plot all the ideal values on all the graphs
                self._plot_and_update_ideals(trajectories, t)

                # Update time
                t += dt
        # Else, print results if no GUI
        else:
            print()
            print('Input Parameters:')
            print(f'  Initial Speed: {self.speed:.1f} m/s, Launch Angle: {self.angle:.1f}°')
            print(f'  Environment: {str(self.environment.type)}', end=' ')
            print(f'(Gravity: {self.environment.gravity:.3f} m/s²,', end=' ')
            print(f'Air Density: {self.environment.air_density:.3f} kg/m³)')
            print(f'  Projectile: {str(self.projectile.type)}', end=' ')
            print(f'(Mass: {self.projectile.mass:.3f} kg,', end=' ')
            print(f'Surface Area: {self.projectile.area:.3f} m²)')
            print()
            print(f'Max possible distance @ 45°: {self.max_possible_dist:.3f} m')
            print(f'Max possible height @ 45°: {self.max_possible_height/2:.3f} m')
            print(f'Max possible height @ 90°: {self.max_possible_height:.3f} m')
            print(f'Max possible flight time @ 45°: {self.max_possible_flight_time/math.sqrt(2):.3f} s')
            print(f'Max possible flight time @ 90°: {self.max_possible_flight_time:.3f} s')
            print()
            print(f'Max height: {self.max_height:.3f} m')
            print(f'Time to max height: {self.time_to_max_height:.3f} s')
            print(f'Distance at max height: {self.dist_at_max_height:.3f} m')
            print()
            print(f'Total flight time: {self.total_flight_time:.3f} s')
            print(f'Total distance: {self.total_distance:.3f} m')
            print()

Static methods

def disable_gui(no_gui: bool) ‑> None

Enables or disables the GUI.

Args

no_gui : bool
Flag to indicate where GUI should be disabled (True = disable GUI).
def quit_simulation() ‑> None

Stop the VPython server.

Methods

def run_simulation(self) ‑> None

Run the projectile motion simulation and visualize the results.

This method sets up the visualization, calculates the projectile's trajectory, and updates the display in real-time.

class Trajectory (pos: vp.vector, vel: vp.vector)

Data class to store trajectory information (Position (x, y), Velocity (vx, vy))

Expand source code
@dataclass
class Trajectory:
    """
    Data class to store trajectory information (Position (x, y), Velocity (vx, vy))
    """
    pos: vp.vector
    vel: vp.vector

Class variables

var pos : vp.vector
var vel : vp.vector