Someone (Corey Deuel) has done this experimentally :), please see https://youtu.be/k6oNbNzoxc4
Edit: Another even better experiment (15ball break) by Chinese pro Zhang Kunpeng in 2016 https://www.bilibili.com/video/BV17bg7zXEZc
I asked GPT to write a python code to simulate the break using Hertz model. It works quite well. Since Professor Belk's code is not available anymore I'll post mine here. You can change ball0(cue ball)'s initial condition like position and velocity to see different break patterns. I also defined a few initial setups like 2 balls in a row, 3 balls, 9-ball and 10-ball rack. I also changed the stiffness constant to $10^7$ instead of $10^{11}$ since it is the experimental data by Marlow. Please see Dr.Dave's article for reference https://drdavepoolinfo.com/technical_proofs/new/TP_B-29.pdf
Below is the code:
import numpy as np
from scipy.integrate import solve_ivp
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
# ---------- Constants ----------
RADIUS = 0.0572 / 2 # meters (standard pool ball)
MASS = 0.169 # kg
KN = 7.266 * 1e7 # Hertz contact stiffness
N_BALLS = 15 # 1 cue + 3 object balls
# ---------- Initial Conditions ----------
def initial_setup():
# Triangle arrangement
dy = RADIUS
dx = RADIUS * np.sqrt(3)
positions = np.zeros((N_BALLS+1, 2))
if N_BALLS == 1:
positions[1] = [0.5, 0]
if N_BALLS == 2: # 3-body config
positions[1] = [0.5, 0]
positions[2] = [0.5 + 2 * RADIUS, 0]
if N_BALLS == 3: # 3-ball triangle
positions[1] = [0.5, 0]
positions[2] = [0.5 + dx, dy]
positions[3] = [0.5 + dx, -dy]
if N_BALLS == 4: # 4-ball rhombus
positions[1] = [0.5, 0]
positions[2] = [0.5 + dx, dy]
positions[3] = [0.5 + dx, -dy]
positions[4] = [0.5 + 2*dx, 0]
if N_BALLS == 9: # 9-ball rack
positions[1] = [0.5, 0]
positions[2] = [0.5 + dx, dy]
positions[3] = [0.5 + dx, -dy]
positions[4] = [0.5 + 2 * dx, 2 * dy]
positions[5] = [0.5 + 2 * dx, 0]
positions[6] = [0.5 + 2 * dx, -2 * dy]
positions[7] = [0.5 + 3 * dx, dy]
positions[8] = [0.5 + 3 * dx, -dy]
positions[9] = [0.5 + 4 * dx, 0]
if N_BALLS == 10: # 10-ball rack
positions[1] = [0.5, 0]
positions[2] = [0.5 + dx, dy]
positions[3] = [0.5 + dx, -dy]
positions[4] = [0.5 + 2 * dx, 2 * dy]
positions[5] = [0.5 + 2 * dx, 0]
positions[6] = [0.5 + 2 * dx, -2 * dy]
positions[7] = [0.5 + 3 * dx, 3 * dy]
positions[8] = [0.5 + 3 * dx, dy]
positions[9] = [0.5 + 3 * dx, -dy]
positions[10] = [0.5 + 3*dx, -3 * dy]
if N_BALLS == 15: # 15-ball triangle
positions[1] = [0.5, 0]
positions[2] = [0.5 + dx, dy]
positions[3] = [0.5 + dx, -dy]
positions[4] = [0.5 + 2 * dx, 2 * dy]
positions[5] = [0.5 + 2 * dx, 0]
positions[6] = [0.5 + 2 * dx, -2 * dy]
positions[7] = [0.5 + 3 * dx, 3 * dy]
positions[8] = [0.5 + 3 * dx, dy]
positions[9] = [0.5 + 3 * dx, -dy]
positions[10] = [0.5 + 3*dx, -3 * dy]
positions[11] = [0.5 + 4 * dx, 4 * dy]
positions[12] = [0.5 + 4 * dx, 2 * dy]
positions[13] = [0.5 + 4 * dx, 0]
positions[14] = [0.5 + 4 * dx, -2 * dy]
positions[15] = [0.5 + 4 * dx, -4 * dy]
# Cue ball starts further back
positions[0] = [0.4, 0.00]
# Cue ball velocity toward the triangle
velocities = np.zeros_like(positions)
velocities[0] = [9, 0] # m/s
return positions, velocities
# ---------- Hertzian Force Function ----------
def hertz_force(r_i, r_j):
rij = r_j - r_i
dist = np.linalg.norm(rij)
delta = 2 * RADIUS - dist
if delta > 0:
n_hat = rij / dist
return KN * delta**1.5 * n_hat
else:
return np.zeros(2)
# ---------- ODE System ----------
def system(t, y):
positions = y[:2*(N_BALLS+1)].reshape(((N_BALLS+1), 2))
velocities = y[2*(N_BALLS+1):].reshape(((N_BALLS+1), 2))
accelerations = np.zeros_like(velocities)
for i in range((N_BALLS+1) + 1):
for j in range(i + 1, (N_BALLS+1)):
F = hertz_force(positions[i], positions[j])
accelerations[i] -= F / MASS
accelerations[j] += F / MASS
dydt = np.zeros_like(y)
dydt[:2*(N_BALLS+1)] = velocities.flatten()
dydt[2*(N_BALLS+1):] = accelerations.flatten()
return dydt
# ---------- Run Simulation ----------
def run_sim():
pos, vel = initial_setup()
y0 = np.concatenate([pos.flatten(), vel.flatten()])
t_span = (0, 0.1)
sol = solve_ivp(system, t_span, y0, method='RK45', max_step=1e-4)
return sol
# ---------- Optional Plot (Static Final Positions) ----------
def plot_result(sol):
positions = sol.y[:2*(N_BALLS+1), -1].reshape(((N_BALLS+1), 2))
colors = ['r', 'b', 'g', 'k']
for i in range((N_BALLS+1)):
plt.plot(positions[i, 0], positions[i, 1], 'o', label=f'Ball {i}', color=colors[i])
plt.title("Final Ball Positions")
plt.gca().set_aspect('equal')
plt.grid(True)
plt.legend()
plt.show()
def animate_trajectories(sol):
positions_over_time = sol.y[:2 * (N_BALLS+1)].T.reshape(-1, (N_BALLS+1), 2) # shape (T, N, 2)
fig, ax = plt.subplots()
ax.set_aspect('equal')
ax.set_xlim(-0.05, 1.5)
ax.set_ylim(-0.5, 0.5)
ax.set_title("Billiard Break Shot Trajectories")
# Plot object for each ball
colors = ['white','yellow', 'yellow', 'blue', 'red', 'purple', 'orange', 'green', 'maroon', 'black',
'yellow', 'blue', 'red', 'purple', 'orange', 'green']
ball_dots = [plt.plot([], [], 'o', markersize=12.5)[0] for _ in range((N_BALLS+1))]
trails = [ax.plot([], [], '-', lw=1)[0] for _ in range((N_BALLS+1))]
history_x = [[] for _ in range((N_BALLS+1))]
history_y = [[] for _ in range((N_BALLS+1))]
def update(frame):
for i in range((N_BALLS+1)):
x, y = positions_over_time[frame, i]
ball_dots[i].set_data([x], [y])
history_x[i].append(x)
history_y[i].append(y)
trails[i].set_data(history_x[i], history_y[i])
return ball_dots + trails
ani = FuncAnimation(fig, update, frames=len(sol.t), interval=10, blit=False)
# ani.save("billiard_simulation.mp4", fps=60, dpi=200, writer="ffmpeg")
plt.show()
if __name__ == "__main__":
sol = run_sim()
animate_trajectories(sol)