Skip to content

Instantly share code, notes, and snippets.

@wf34
Created June 18, 2025 21:12
Show Gist options
  • Save wf34/1b26f5dc8ef4e30fee94439356fd00e8 to your computer and use it in GitHub Desktop.
Save wf34/1b26f5dc8ef4e30fee94439356fd00e8 to your computer and use it in GitHub Desktop.
Single-file 2D Physics simulation: Multi-Body, Finite Element method, Co-simulation
# context is available at https://wf34.ws/works/learnings-of-multibody-dynamics-workshop-2025/
# requirements: pip3 install typed-argument-parser numpy matplotlib
#
#!/usr/bin/env python3
import bisect
import copy
import os
import typing
import numpy as np
from tap import Tap
from matplotlib import pyplot as plt
from matplotlib.patches import Circle, Rectangle
MB = 'mb'
FEA = 'fea'
CO = 'co'
class SimArgs(Tap):
mode: typing.Literal[MB, FEA, CO]
dest_dir: str
def process_args(self):
self.abs_dest_dir = os.path.abspath(self.dest_dir)
self.gif_path = os.path.join(os.path.dirname(self.abs_dest_dir), f'_{self.mode}.gif')
if os.path.exists(self.dest_dir):
print(f'wont overwrite {self.dest_dir}')
exit(1)
if os.path.exists(self.gif_path):
print(f'wont overwrite {self.gif_path}')
exit(2)
os.mkdir(self.dest_dir)
def render(mode, free_bodies, fixed_bodies, world_bounds, out_dir, index, timestamp):
dest_file = os.path.join(out_dir, f'{index:06d}.png')
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_xlim(*world_bounds[0])
ax.set_ylim(*world_bounds[1])
ax.set_aspect('equal')
ax.grid(True)
for fb in fixed_bodies:
x, y = fb.get_coords()
ax.plot(x, y, color='k', linewidth=1)
if mode in {MB, CO}:
for bdy in free_bodies:
theta = bdy.pos[0]
R = bdy.radius
circle = Circle(bdy.pos[1:], R, fill=False, edgecolor='k', linewidth=1)
h_bar = Rectangle(bdy.pos[1:] - [R, 0.], 2 * R, 0., angle=np.degrees(theta), fill=False, edgecolor='k', linewidth=1, rotation_point='center')
v_bar = Rectangle(bdy.pos[1:] - [0., R], 0, 2 * R, angle=np.degrees(theta), fill=False, edgecolor='k', linewidth=1, rotation_point='center')
ax.add_patch(circle)
ax.add_patch(h_bar)
ax.add_patch(v_bar)
ax.set_title(f't = {timestamp:.3f}\n')
fig.savefig(dest_file, bbox_inches='tight')
plt.close(fig)
del fig
class body():
def __init__(self, mass, pos, vel, inertia = None, radius = None):
self.mass = mass
self.pos = pos
self.vel = vel
assert inertia != radius
if inertia is None:
self.radius = radius
self.inertia = .5 * mass * radius ** 2
elif radius is None:
self.inertia = inertia
M = np.array([[self.inertia, 0, 0],
[0, self.mass, 0],
[0, 0, self.mass]])
self.invM = np.linalg.inv(M)
class mesh_body():
element_length = 1.
P = 50 # points per element
radius = 0.5
def __init__(self, mass, pos, span):
self.mass = mass
self.pos = pos
self.span = span
L = self.element_length
self.N = int((span[1] - span[0]) // L) # elements count
self.M = self.N+1 # nodes count
self.rho = 2. * mass / ((span[1] - span[0]) * np.pi * self.radius ** 2)
self.nodes_params = np.zeros((2*self.M, 1),)
self.node_velocities = np.zeros((2*self.M, 1),)
self.node_accelerations = np.zeros((2*self.M, 1),)
self.K = None
self.K_eff_red_inv = None
self.M_global = None
self.C = None
def get_coords(self):
assert self.nodes_params is not None
X = []
Y = []
L = self.element_length
for element_index in range(self.N):
x_offset = element_index * L
for p in np.linspace(0, 1, self.P):
n_1 = 1 - 3*p**2 + 2*p**3
n_2 = L*(p - 2*p**2 + p**3)
n_3 = 3*p**2 - 2*p**3
n_4 = L*(-p**2 + p**3)
w = np.dot([n_1, n_2, n_3, n_4], self.nodes_params[2*element_index:2*element_index+4, :].ravel()).item()
X.append(x_offset + p * L)
Y.append(self.pos+w)
return X, Y
def make_contact_force(self, mode, collision_index):
if mode != CO:
return None
left_node_index = collision_index // self.P
right_node_index = left_node_index + 1
assert right_node_index < self.M
a = (collision_index - left_node_index * self.P) / self.P
b = 1. - a
max_force_magnitude_newtons = -1e+5
f = max_force_magnitude_newtons
a2 = a ** 2
b2 = b ** 2
L = self.element_length
L2 = L ** 2
L3 = L ** 3
# [v_i]
# [theta_i]
F = np.zeros((2*self.M, 1))
F[2 * left_node_index + 0, 0] = b2 * (3*a+b) / L3 * f
F[2 * left_node_index + 1, 0] = -a * b2 / L2 * f
F[2 * right_node_index + 0, 0] = a2 * (3*b + a) / L3 * f
F[2 * right_node_index + 1, 0] = a2 * b / L2 * f
#print('coeffs ', b2 * (3*a+b) / L3, -a * b2 / L2, a2 * (3*b + a) / L3, a2 * b / L2)
return F
def solve_for_contact_normal(self, collision_index):
half_step = self.P
_, y = self.get_coords()
left_b = max([0, collision_index-half_step])
right_b = min([len(y), collision_index+half_step])
y_chunk = np.array(y[left_b:right_b])
offset = self.P // 5
y_chunk_fw = np.roll(y_chunk, offset)
diff = y_chunk[offset:] - y_chunk_fw[offset:]
slope_fit = np.mean(diff)
slope_angle = np.arctan(slope_fit)
return np.array([-np.sin(slope_angle), np.cos(slope_angle)]), slope_angle
def find_collision(fixed_body, free_body):
EPS = 0.03
ball_x, ball_y = free_body.pos[1:]
x, y = fixed_body.get_coords()
ind = bisect.bisect_left(x, ball_x)
if ind == len(x):
return False, None
return y[ind] + free_body.radius + EPS > ball_y, ind
def propagate_fixed(mode, now, delta_t, fixed_bodies, Fs):
assert len(Fs) <= 1
delta_t_sq = delta_t ** 2
E_steel = 2.e+6
rez_bodies = []
for fixed_body in fixed_bodies:
rez_bodies.append(copy.deepcopy(fixed_body))
if mode == MB:
continue
fb = rez_bodies[-1]
N = fb.N
M = fb.M
newmark_beta = .25
newmark_gamma = .5
if fb.K_eff_red_inv is None:
I = np.pi * fb.radius ** 4 / 4.
L = fb.element_length
L2 = L ** 2
L3 = L ** 3
K_e = np.array([[ 12, 6*L, -12, 6*L ],
[ 6*L, 4*L2, -6*L, 2*L2 ],
[ -12, -6*L, 12, -6*L ],
[ 6*L, 2*L2, -6*L, 4*L2 ]]) * E_steel * I / L3
mu = fb.rho * np.pi * fb.radius ** 2 / 2
M_e = np.array([[ 156, 22*L, 54, -13*L ],
[ 22*L, 4*L2, 13*L, -3*L2 ],
[ 54, 13*L, 156, -22*L ],
[-13*L, -3*L2, -22*L, 4*L2]]) * mu * L / 420.
K = np.zeros((2*M, 2*M),)
M_global = np.zeros((2*M, 2*M),)
alpha_rayleigh = .9
beta_rayleigh = .9
connectivity = lambda e, k: 2*e + k
for element_id in range(N):
assert element_id+1 < M
for i in range(4):
for j in range(4):
row = connectivity(element_id, i)
col = connectivity(element_id, j)
K[row, col] += K_e[i, j]
M_global += M_e[i, j]
C = alpha_rayleigh * M_global + beta_rayleigh * K
# C = np.zeros((2*M, 2*M),)
fb.C = C
fb.K = K
fb.M_global = M_global
K_eff = K + (newmark_gamma / (newmark_beta * delta_t)) * C + 1. / (newmark_beta * delta_t_sq) * M_global
print('K eff full', np.linalg.cond(K_eff))
K_eff_red = K_eff[2:-2, 2:-2]
print('K eff red', np.linalg.cond(K_eff_red))
fb.K_eff_red_inv = np.linalg.inv(K_eff_red)
u_prev = fb.nodes_params
u_prev_dot = fb.node_velocities
u_prev_ddot = fb.node_accelerations
# 1)
u_pred = u_prev + delta_t * u_prev_dot + (.5 - newmark_beta) * delta_t_sq * u_prev_ddot
u_pred *= .99
u_pred[:2, :] = 0.
u_pred[-2:, :] = 0.
u_pred_dot = u_prev_dot + (1. - newmark_gamma) * delta_t * u_prev_ddot
u_pred_dot[:2, :] = 0.
u_pred_dot[-2:, :] = 0.
u_pred_dot *= .97
u_pred_ddot = u_prev_ddot
u_pred_ddot *= .9
# 2) is above
K_eff_red_inv = fb.K_eff_red_inv
# 3)
F = Fs[0] if len(Fs) == 1 else np.zeros((2*fb.M, 1))
F_eff = F + fb.M_global @ (u_pred / (newmark_beta * delta_t_sq) + \
u_pred_dot / (newmark_beta * delta_t) + \
((1. / (2. * newmark_beta)) - 1.) * u_pred_ddot) + \
fb.C @ (newmark_gamma * u_pred / (newmark_beta * delta_t) + u_pred_dot + \
delta_t * (newmark_gamma / (2. * newmark_beta) - 1.) * u_pred_ddot)
# 4)
F_eff = F_eff[2:-2,:]
u_red = K_eff_red_inv @ F_eff
u = np.zeros((2*M, 1),)
u[2:-2, :] = u_red
# 5)
fb.nodes_params = u
fb.node_accelerations = (u - u_pred)/(newmark_beta * delta_t_sq) - u_pred_dot / (newmark_beta * delta_t)
fb.node_accelerations[:2, :] = 0.
fb.node_accelerations[-2:, :] = 0.
fb.node_velocities = u_pred_dot + newmark_gamma * delta_t * fb.node_accelerations
fb.node_velocities[:2, :] = 0.
fb.node_velocities[-2:, :] = 0.
E_pot = (0.5 * u.T @ fb.K @ u).item()
E_kin = (0.5 * fb.node_velocities.T @ fb.M_global @ fb.node_velocities).item()
print(f'{now:.3f}, kin: {E_kin:.3f}, pot: {E_pot:.3f}')
return rez_bodies
def make_fea_force(mode, t, force_time_end, beam: mesh_body):
if FEA != mode or t >= force_time_end:
return None
mid_id = beam.M // 2
max_force_magnitude_newtons = -1e+4
a = t / force_time_end
f = np.zeros((2*beam.M, 1))
f[2*mid_id, 0] = np.sin(a * np.pi / 2) * max_force_magnitude_newtons
return f
def propagate(mode, now, delta_t, free_bodies, fixed_bodies, forces):
force_for_fea = list(filter(lambda f: f.shape[0] > 3, forces))
pred_fixed_bodies = propagate_fixed(mode, now, delta_t, fixed_bodies, force_for_fea)
prop_bodies = []
contact_force = None
for bdy in free_bodies:
prop_bodies.append(copy.deepcopy(bdy))
if mode == FEA:
continue
curr_b = prop_bodies[-1]
a = (curr_b.invM @ forces[0][np.newaxis, :].T).T.ravel()
curr_b.vel += a * delta_t
curr_b.pos += curr_b.vel * delta_t
for fb in pred_fixed_bodies:
collides, collision_index = find_collision(fb, curr_b)
if collides:
contact_n, slope_angle = fb.solve_for_contact_normal(collision_index)
print(f'collides! with slope angle {np.degrees(slope_angle)}, contact normal: {contact_n}')
ra = contact_n * curr_b.radius
perp_vec = lambda r: np.array([-r[1], r[0]])
ra_perp = perp_vec(ra)
v_rel = curr_b.vel[1:] + curr_b.vel[0] * ra_perp
print(f'ra_perp: {ra_perp}, angular velocity: {curr_b.vel[0]}, spatial velocity: {curr_b.vel[1:]}')
v_rel_n = np.dot(contact_n, v_rel)
e = 0.85
I_inv = 1. / curr_b.inertia
m_inv = 1. / curr_b.mass
beam_m_inv = 1. / fb.mass
cross2d = lambda r, n: r[0] * n[1] - r[1] * n[0]
denom = m_inv + beam_m_inv + I_inv * cross2d(ra, contact_n) ** 2
j = -(1. + e) * v_rel_n / denom
J = j * contact_n
smth_omega = I_inv * cross2d(ra, J)
smth_lin = m_inv * J
print(f'at {now:.3f} v_rel {v_rel} || change: {smth_omega:.1f} {smth_lin}, impulse: {J}')
curr_b.vel[0] += smth_omega
curr_b.vel[1:] += smth_lin
contact_force = fb.make_contact_force(mode, collision_index)
else:
contact_force = None
return prop_bodies, pred_fixed_bodies, contact_force
def gif_maker(dest_dir, gif_path, mode):
command = f'ffmpeg -framerate 30 -pattern_type glob -i "{dest_dir}/*.png" -vf "fps=30,scale=600:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 {gif_path}'
os.system(command)
def do_sim(args: SimArgs):
time_end = 11.
delta_t = 3.e-2
world_bounds = [[-.5, 20], [-1.5, 15]]
ball = body(5., np.array([0., 0., 10.]), np.array([np.radians(3), 2., 3.]), None, .3)
beam = mesh_body(80, 0., world_bounds[0])
bodies = [ball]
fixed_bodies = [beam]
gravity = np.array([0, 0, -12.81])
force_time_end = time_end / 4.
dynamic_force = None
last_contact_time = None
nominal_contact_force = None
for i, t in enumerate(np.arange(0, time_end, delta_t)):
dynamic_force = make_fea_force(args.mode, t, force_time_end, beam)
forces = [gravity]
if dynamic_force is not None:
forces.append(dynamic_force)
elif last_contact_time is not None:
if t > last_contact_time + force_time_end:
last_contact_time = None
nominal_contact_force = None
else:
assert nominal_contact_force is not None
forces.append(copy.deepcopy(nominal_contact_force))
forces[-1] *= np.cos(np.pi / 2 * (t-last_contact_time) / force_time_end)
bodies, fixed_bodies, ret_contact_force = propagate(args.mode, t, delta_t, bodies, fixed_bodies, forces)
if ret_contact_force is not None:
nominal_contact_force = ret_contact_force
last_contact_time = t
if 0 == i % 3:
render(args.mode, bodies, fixed_bodies, world_bounds, args.dest_dir, i, t)
gif_maker(args.dest_dir, args.gif_path, args.mode)
if __name__ == '__main__':
args = SimArgs().parse_args()
do_sim(args)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment