Source code for Physics

"""Physics contains the objects relating to mathematical calculations, such as Vectors, Forces, and PhysicsObjects.
There are no UI components in this module.

All displacements are calculated on an abstract plane - :class:`Ui.PhysicsCanvas` is responsible for translating to
canvas coordinates.

One semi-exception - PhysicsObjects handle their own collision detection, and they do that by referencing the
collection of PhysicsObjects in PhysicsCanvas.

All the physics logic and calculation should be handled here.
 """

import math

import Substance, Utility
import Particle


[docs]class Vector: """ Represents direction and magnitude. Vectors can be broken into x and y components and reassembled from those components. :param angle: The angle, in radians :type angle: number :param magnitude: The magnitude of the Vector :type magnitude: number """ def __init__(self, angle, magnitude): """ constructor """ self.angle = angle self.magnitude = magnitude self.y = 0 self.x = 0 self.calculate_components()
[docs] def calculate_components(self): """ Calculates components from angle and magnitude. :math:`y = \\text{vector}_\\text{mag} * \\sin{\\theta}` :math:`x = \\text{vector}_\\text{mag} * \\cos{\\theta}` """ self.y = self.magnitude * math.sin(self.angle) self.x = self.magnitude * math.cos(self.angle)
[docs] def calculate_angles(self): """ Calculates angle and mag from components. :math:`\\theta= \\arctan{(\\frac{y}{x})}` :math:`\\text{mag} = \\sqrt{x^2+y^2}` """ y = self.y x = self.x self.angle = math.atan2(y, x) self.magnitude = math.sqrt(x*x + y*y)
[docs] def dot_product(self, other_vector): """ Gets the scalar dot product of this and another vector Non mutating :param other_vector: A vector to multiply with this one :return: A scalar :rtype: number """ return (self.x * other_vector.x) + (self.y * other_vector.y)
[docs] @staticmethod def make_vector_from_components(x, y): """ Static: Create a new Vector from x and y values. :param x: The x component :type x: number :param y: The y component :type y: number :returns: A brand new Vector :rtype: Vector """ angle = math.atan2(y, x) magnitude = math.sqrt(x*x + y*y) return Vector(angle, magnitude)
[docs] def add_make(self, other_vector): """ Use this to have a new vector return from the addition and the input vectors to be unchanged. No existing Vector will mutate. :param other_vector: A vector to add to this one. :type other_vector: Vector :returns: A brand new Vector :rtype: Vector """ new_x = other_vector.x + self.x new_y = other_vector.y + self.y return Vector.make_vector_from_components(new_x, new_y)
[docs] def subtract_make(self, other_vector): """ Use this to have a new vector return from the subtraction and the input vectors to be unchanged. No existing Vector will mutate. :param other_vector: A vector to subtract FROM this one. :type other_vector: Vector :returns: A brand new Vector :rtype: Vector """ new_x = self.x - other_vector.x new_y = self.y - other_vector.y return Vector.make_vector_from_components(new_x, new_y)
[docs] def add(self, other_vector): """ Use this to MUTATE this Vector by adding another Vector to it. The other Vector will be unchanged. :param other_vector: A vector to add to this one. :type other_vector: Vector """ self.x += other_vector.x self.y += other_vector.y self.calculate_angles()
[docs] def subtract(self, other_vector): """ Use this to MUTATE this Vector by subtracting another Vector from it. The other Vector will be unchanged. :param other_vector: A vector to subtract from this one. :type other_vector: Vector """ self.x -= other_vector.x self.y -= other_vector.y self.calculate_angles()
[docs] def rotate(self, radians): """ Mutates this Vector by adding radians to its angle and recalculating its components. :param radians: Radians to add to this Vector's angle. :type radians: number """ new_angle = self.angle + radians twoPi = math.pi * 2 if new_angle > twoPi: new_angle = -twoPi else: new_angle = twoPi - new_angle self.angle = new_angle self.calculate_components()
[docs] def scale(self, scalar): """ Mutates this Vector by multiplying its magnitude by the scalar. :param scalar: Scalar to multiply this Vector's angle by. :type scalar: number """ self.magnitude *= scalar self.calculate_components()
[docs] def scale_make(self, scalar): """ Creates a new Vector of a magnitude equal to this Vector's magnitude multiplied by the scalar, and returns the new Vector.. :param scalar: Scalar to multiply this Vector's angle by. :type scalar: number :returns: A brand new Vector :rtype: Vector """ new_magnitude = self.magnitude * scalar return Vector(self.angle, new_magnitude)
[docs] def normal_make(self): """ Returns a new magnitude 1 vector in the same angle. :return: new Vector :rtype: Vector """ x = self.x / self.magnitude y = self.y / self.magnitude return Vector.make_vector_from_components(x, y)
[docs] @staticmethod def make_directional_vector(direction='S', magnitude=1): """ Static method which creates a directional Vector from compass coordinates passed as a character. Direction string controls vector angle. I.e.: 'E': 0 radians (0 degrees) 'W': 3.1415 radians (180 degrees) Valid directions are e,ne,n,nw,w,sw,s,se :param direction: character for direction :type direction: str :param magnitude: vector magnitude :type magnitude: number """ angle = 0 case = direction.upper() if case == 'N': angle = 1.5707963267948966 # don't know if this improves processing time elif case == 'NW': angle = 2.356194490192345 # but i wanted to write the radians out elif case == 'W': angle = 3.141592653589793 elif case == 'SW': angle = 3.9269908169872414 elif case == 'S': angle = 4.71238898038469 elif case == 'SE': angle = 5.497787143782138 elif case == 'E': angle = 0 elif case == 'NE': angle = 0.7853981633974483 return Vector(angle, magnitude)
def __repr__(self): """ Changes what's output when calling print(Vector) so useful information about the vector is displayed :return: A string of details about this vector :rtype: string """ return f"Vec({round(self.x,3)}, {round(self.y,3)}) a:{round(math.degrees(self.angle),3)} mag:{round(self.magnitude, 3)}"
[docs]class Force(Vector): """ A force which operates over time, executing a single force on the object once per sec. If constant is true, the force does not deplete and will continue acting on the object. PhysicsObjects have lists of forces currently operating on them. As the PhysicsObject updates, it gets a Vector representing a force for each force acting on it. That force is scaled based on the time of the update interval. It also reduces the 'remaining' attribute of the Force until the Force is depleted. :param angle: Angle in radians :type angle: number :param magnitude: Magnitude in Newtons (per second) :type magnitude: number :param duration: Seconds force is exerted for :type duration: number :param constant: Whether force depletes or not :type constant: bool """ def __init__(self, angle, magnitude, duration=1.0, constant=False): Vector.__init__(self, angle, magnitude) self.force_magnitude = magnitude self.remaining = duration self.constant = constant
[docs] @staticmethod def make_directional_force(direction, magnitude, duration=1.0, constant=False): """ Similar to `Vector.make_directional_vector`, creates a force in a cardinal direction. :param direction: N,S,E,W,NE,SE,NW,SW :type direction: str :param magnitude: In Newtons :type magnitude: number :param duration: In seconds :type duration: number :param constant: Whether force depletes :type constant: boolean :return: A new force :rtype: Force """ vec = Vector.make_directional_vector(direction, magnitude) return Force(vec.angle, vec.magnitude, duration, constant)
[docs] def update(self, interval): """ Scales the current vector magnitude to the interval, so that `self.force_magnitude` is delivered each second; if updates occur more frequently than once a second, this causes this forces temporary magnitude to be lower to account for that. :param interval: Time since last update, in seconds. :type interval: number """ if interval < self.remaining: interval = self.remaining - interval if self.remaining > 0: self.magnitude = self.force_magnitude * interval self.calculate_components() self.remaining -= interval if self.constant: self.remaining = 1
[docs]class GravitationalForceGenerator: def __init__(self, planet, moon): self.planet = planet self.moon = moon self.planet.dependent_force_generators.append(self) self.moon.dependent_force_generators.append(self) self.grav_sum = planet.mass * moon.mass # omitted - the gravitational constant self.remaining = 1 """ Connects two objects together with 'gravity'. Currently not accurately implemented, because planetary scales make for poor visibility on the UI. Three objects keep a reference to a gravitational force; the `class:Ui.PhysicsCanvas` object and each `class:Physics.ForceObject` that are connected with the gravity. When remove() is called on a GravitationalForceGenerator, it removes each of these references so it will no longer be updated. GraviationalForceGenerator is updated directly by `class:Ui.PhysicsCanvas`; each update, it calculates the appropriate graviational pull for its two reference ForceObjects, then adds a force of the appropriate angle and magnitude to their force lists. :param planet: An object to connect with gravity :type planet: ForceObject :param moon: An object to connect with gravity :type moon: ForceObject """
[docs] def update(self, interval): """ Gets the location of self.planet and self.moon, figures out F_G between them, then adds opposite forces to each one reduced by the interval. :math:`F_G = \\frac{Gm_1m_2}{r^2}` :param interval: Update time, seconds :type interval: number """ planet_off_x = self.moon.displacement.x - self.planet.displacement.x planet_off_y = self.moon.displacement.y - self.planet.displacement.y planet_vector = Vector.make_vector_from_components(planet_off_x, planet_off_y) reversed_vector = Vector.make_vector_from_components(planet_off_x*-1, planet_off_y*-1) force_magnitude = (math.sqrt(self.planet.mass * self.moon.mass)/2)*interval # not accurate gravitational force self.planet.forces.append(Force(planet_vector.angle, force_magnitude)) self.moon.forces.append(Force(reversed_vector.angle, force_magnitude))
[docs] def remove(self): moon_forces = self.moon.dependent_force_generators moon_i = moon_forces.index(self) planet_forces = self.planet.dependent_force_generators planet_i = planet_forces.index(self) moon_forces.pop(moon_i) planet_forces.pop(planet_i) main_list = self.planet.physics_canvas.interacting_forces main_i = main_list.index(self) main_list.pop(main_i)
[docs]class PhysicsObject: """ An object which has vectors for acceleration, velocity, and displacement. Abstracts the physics calculations - :class:`Ui.PhysicsCanvas` is responsible for the rendering, and translating the displacement of the PhysicsObject into the Tkinter Canvas coordinate space. Each update, it determines how much force should be applied based on the interval and the list of forces currently affecting this object. From the net force and mass, it calculates acceleration. From the acceleration, it calculates velocity. From velocity, displacement is calculated. :math:`F_{net} = \\sum{F}\\text{ Newtons}` :math:`a = \\frac{F_\\text{net}}{m}\\text{ m/s}^2` :math:`v = at + v_0 \\text{ m/s}` :math:`s = vt + s_0 = \\frac{1}{2}at^2+v_0t+s_0 \\text{ m}` :param material: The material from :class:`Substance.Material` used in this object. Determines its color and size based on the material density. :type material: :class:`Substance.Material` :param mass: The mass of the object. (kg) :type mass: Number """ def __init__(self, material, mass): self.physics_canvas = None # added by physics canvas at time of adding """Reference to canvas added when object rendered on canvas""" self.canvas_id = None # set by physics canvas at time of drawing """Used by the tkinter canvas to reference the shape linked to this object""" self.displacement = Vector(0,0) """A vector of positional offset from the 0,0 of world origin""" self.velocity = Vector(0,0) """A vector of velocity magnitude and angle""" self.acceleration = Vector(0,0) """A vector of acceleration magnitude and angle""" self.material = material """The material the PhysicsObject is made of""" self.mass = mass """mass in kg""" self.volume = mass / self.material.density self.side = self.volume**(1/3) """ Length of a side in m""" self.width = self.side """ width in m""" self.height = self.side """ height in m """ self.forces = [] """ Currently active forces affecting this object """ self.dependent_force_generators = [] """ Force generators like :class:`Physics.GravitationalForceGenerator`""" self.net_force_vector = Vector(0,0)
[docs] def update(self, interval): """ Gets a new vector equal to multiplying acceleration by Velocity and adds it to the velocity, mutating the velocity vector. :math:`v = at + v_0` Does the same for displacement. :math:`s = vt + s_0` Tells physicsCanvas to move the rendering of the oval. :param interval: The time since last update. :type interval: number """ self.net_force_vector = Vector(0, 0) non_expired_forces = [] for i in range(0, len(self.forces)): # get the net force for this interval force = self.forces[i] force.update(interval) self.net_force_vector.add(force) if force.remaining > 0: non_expired_forces.append(force) self.forces = non_expired_forces self.acceleration = Vector(self.net_force_vector.angle, self.net_force_vector.magnitude/self.mass) if self.displacement.y - self.side > self.physics_canvas.min_y + self.side: self.velocity.add(self.acceleration) # a check to see if it should stop y_min = self.physics_canvas.min_y if not self.check_collision(interval): self.displacement.add(self.velocity) self.physics_canvas.move_physics_object(self)
[docs] def collide(self, other_object, my_next_displacement, other_next_displacement, interval): """ Called by the check_collision function. Next displacements calculated there are passed as parameters to avoid redundant calculations. The elastic collision formulas: link - https://imada.sdu.dk/~rolf/Edu/DM815/E10/2dcollisions.pdf :param other_object: The colliding object :type other_object: ForceObject :param my_next_displacement: Displacement vector that would realize if no collision :type my_next_displacement: Vector :param other_next_displacement: Displacement vector for other object that would realize if no collision :type other_next_displacement: Vector :param interval: Interval of distance move, second(s) :type interval: number """ # line1 = Utility.get_line(self.displacement.x, self.displacement.y, my_next_displacement.x, my_next_displacement.y) # line2 = Utility.get_line(other_object.displacement.x, other_object.displacement.y, other_next_displacement.x, other_next_displacement.y) # collision_x, collision_y = Utility.find_intersecting_point(line1, line2) # point of collision # if other_object.displacement.x < collision_x: # these ifs put colliders on appropriate side # other_object.displacement.x = collision_x - other_object.side # self.displacement.x = collision_x + self.side # elif other_object.displacement.x > collision_x: # other_object.displacement.x = collision_x + other_object.side # self.displacement.x = collision_x - self.side # if other_object.displacement.y < collision_y: # other_object.displacement.y = collision_y - other_object.side # self.displacement.y = collision_y + self.side # elif other_object.displacement.y > collision_y: # other_object.displacement.y = collision_y + other_object.side # self.displacement.y = collision_y - self.side # self.displacement.calculate_angles() # other_object.displacement.calculate_angles() # calculate the normal vector x_diff = other_object.displacement.x - self.displacement.x y_diff = other_object.displacement.y - self.displacement.y normal = Vector.make_vector_from_components(x_diff, y_diff) unit_normal = normal.scale_make(1) unit_normal.magnitude = 1 unit_normal.calculate_components() # calculate the unit tangent unit_tangent = Vector.make_vector_from_components(unit_normal.y*(-1), unit_normal.x) v1_n = self.velocity.dot_product(unit_normal) v1_t = self.velocity.dot_product(unit_tangent) v2_n = other_object.velocity.dot_product(unit_normal) v2_t = other_object.velocity.dot_product(unit_tangent) # find new normal scalar velocities m_1 = self.mass m_2 = other_object.mass v_1f_mag = (v1_n*(m_1-m_2)+2*m_2*v2_n)/(m_1+m_2) v_2f_mag = (v2_n*(m_2-m_1)+2*m_1*v1_n)/(m_1+m_2) # convert new magnitudes to vectors v_1fn = unit_normal.scale_make(v_1f_mag) v_1ft = unit_tangent.scale_make(v1_t) v_2fn = unit_normal.scale_make(v_2f_mag) v_2ft = unit_tangent.scale_make(v2_t) # final velocity vectors v_1_f = v_1fn.add_make(v_1ft) v_2_f = v_2fn.add_make(v_2ft) self.velocity = v_1_f other_object.velocity= v_2_f self.physics_canvas.move_physics_object(self)
[docs] def check_collision(self, interval): """ The way collision is checked is that the next displacement from the velocity is calculated. For each other extant physics object on the canvas, the next displacement from velocity is calculated. If the lines between the displacements cross, the vectors have 'collided'. This function returns True or False and is used in the update function. If collision doesn't happen, the update function handles simple movement. :param interval: :return: Whether collision happened :rtype: bool """ next_displacement = self.displacement.add_make(self.velocity) # add radius/side length here? for p in self.physics_canvas.physics_objects: if p != self: other_next_displacement = p.displacement.add_make(p.velocity) y_cross = False if self.displacement.y <= p.displacement.y: if next_displacement.y + self.side >= other_next_displacement.y - p.side: y_cross = True elif self.displacement.y >= p.displacement.y: if next_displacement.y - self.side <= other_next_displacement.y + p.side: y_cross = True x_cross = False if self.displacement.x <= p.displacement.x: if next_displacement.x + self.side >= other_next_displacement.x - p.side: x_cross = True elif self.displacement.x >= p.displacement.x: if next_displacement.x - self.side <= other_next_displacement.x + p.side: x_cross = True if x_cross and y_cross: self.collide(p, next_displacement, other_next_displacement, interval) return True return False
[docs] def clear_forces(self): """ Clears forces from self.dependent_force_generators by calling remove() on each """ for f in self.dependent_force_generators: f.remove()
[docs] def get_energy_vector(self): joules = self.velocity.magnitude * self.mass return Vector(self.velocity.angle, joules)
def __repr__(self): """ Changes output of print(PhysicsObject) so useful information about it is displayed :return: A string of details about this vector :rtype: string """ return f"{round(self.mass, 2)}kg {self.material.name} physicsobject, moving at {round(self.velocity.magnitude,2)} meters per sec "