Physics Resolution

Applying collision models to update ball velocities and spin after each detected event.

Resolver — strategy dispatcher

pooltool/physics/resolve/resolver.py:98

Resolver is a plain dataclass. Each field holds the strategy object for one event category. When resolve(event) is called, it reads event.event_type and delegates to the right strategy. Strategies are swappable — pass a custom SimulationEngine to override any of them.

Resolver — field layout and dispatch
# pooltool/physics/resolve/resolver.py:98
@dataclass
class Resolver:
    ball_ball:             BallBallCollisionStrategy
    ball_linear_cushion:   BallLCushionCollisionStrategy
    ball_circular_cushion: BallCCushionCollisionStrategy
    ball_pocket:           BallPocketStrategy
    stick_ball:            StickBallCollisionStrategy
    ball_table:            BallTableCollisionStrategy  # 3D only
    transition:            BallTransitionStrategy

    def resolve(self, event: Event, shot: System) -> None:  # line 116
        match event.event_type:
            case EventType.BALL_BALL:
                self.ball_ball.resolve(event, shot)
            case EventType.BALL_LINEAR_CUSHION:
                self.ball_linear_cushion.resolve(event, shot)
            case EventType.BALL_CIRCULAR_CUSHION:
                self.ball_circular_cushion.resolve(event, shot)
            case EventType.BALL_POCKET:
                self.ball_pocket.resolve(event, shot)
            case EventType.STICK_BALL:
                self.stick_ball.resolve(event, shot)
            case _:  # transitions
                self.transition.resolve(event, shot)

Available strategies

Ball–Ball
FrictionalInelastic
Accounts for friction during contact. Realistic for billiards. Default.
default
Ball–Ball
FrictionalMathavan
Mathavan 2010 model — alternative friction treatment with different spin transfer.
Ball–Ball
FrictionlessElastic
Simple elastic collision. No spin transfer. Fast but less realistic.
Ball–Cushion
StrongeCompliant
Compliant contact model (Stronge). Models cushion deformation. Default.
default
Ball–Cushion
Han2005
Alternative cushion model. Different normal/tangential restitution.
Ball–Cushion
Mathavan2010
Physics-based cushion with friction. Published by Mathavan et al.
Ball–Cushion
ImpulseFrictional
Impulse-based model with Coulomb friction. Alternative formulation.
Stick–Ball
InstantaneousPoint2D
Treats cue-tip contact as an instantaneous off-center impulse. Default.
default
Pocket
CanonicalBallPocket
Sets ball state to pocketed and zeroes velocity. Simple removal.
default
Transition
CanonicalTransition
Updates motion state label and adjusts rvw to exactly satisfy the transition condition.
default

Stick–ball resolution — impulse mechanics

pooltool/physics/resolve/stick_ball/

InstantaneousPoint2D — converting strike params to initial rvw
# The cue strikes the ball with speed V0 at tip offset (a, b)
# relative to the ball center. The impulse is decomposed into:
#   - center-of-mass linear velocity  v = f(V0, theta, phi, a, b, ...)
#   - angular velocity                w = f(V0, theta, phi, a, b, R, M_cue, m_ball, ...)
#
# With offset a=0, b=0: ball slides with no spin → transitions to rolling.
# With b>0 (high contact): topspin → accelerates toward rolling faster.
# With b<0 (low contact): backspin → stun/draw shot.
# With a≠0: sidespin (english) → curve on cushion rebound.
#
# After resolution, the ball is set to SLIDING state.
# squirt.py computes the small angle deflection from sidespin (squirt effect).
stick_ball/instantaneous_point/ — core impulse model  ·  squirt.py — sidespin deflection

Ball–ball resolution — frictional inelastic

pooltool/physics/resolve/ball_ball/core.py

FrictionalInelastic — collision impulse model
# At the moment of collision, ball centers are separated by exactly (R1+R2).
# The collision normal n̂ points from ball 2 center to ball 1 center.
#
# FrictionalInelastic computes:
#   1. Relative contact-point velocity (includes angular contribution)
#   2. Normal impulse J_n = -(1 + e_n) * (relative_vel · n̂) / (1/m1 + 1/m2)
#      where e_n is the coefficient of restitution
#   3. Tangential impulse limited by Coulomb friction: |J_t| ≤ μ * |J_n|
#   4. Apply impulse to both balls: Δv = J/m, Δω = R×J / I
#
# Both balls re-enter SLIDING state after the collision.

Ball–cushion resolution — compliant contact

pooltool/physics/resolve/ball_cushion/

Stronge compliant model — default cushion strategy
# The Stronge (2000) energetic restitution model:
#
# Unlike a simple coefficient-of-restitution bounce, the Stronge model
# integrates the contact dynamics over the compression/restitution phases.
# This naturally handles the coupling between normal and tangential
# (friction) forces during the bounce, producing realistic spin transfer.
#
# Key parameters:
#   e_c   — coefficient of restitution (normal, ~0.85 for billiard rubber)
#   mu_c  — cushion friction coefficient
#   theta_c — cushion nose angle
#
# After the cushion bounce, ball re-enters SLIDING state.

Transition resolution — state label update

CanonicalTransition — adjusting rvw at state boundary
# A SLIDING_ROLLING transition fires when slip velocity → 0.
# At that exact instant:
#   - Update ball.state.s from sliding → rolling
#   - Adjust rvw[2] to satisfy the no-slip constraint exactly:
#       w_perp = v / R  (spin magnitude = velocity / radius)
#
# This prevents floating-point drift from keeping the ball
# perpetually near-but-not-quite rolling.
#
# Similarly for other transitions:
#   ROLLING_STATIONARY   → zero all of rvw[1], rvw[2]
#   ROLLING_SPINNING     → zero rvw[1], keep rvw[2] vertical component
#   SPINNING_STATIONARY  → zero all of rvw[2]

Pre/post-event snapshots

resolver.py:209

_snapshot_initial / _snapshot_final — recording history
# resolver.py:209
def _snapshot_initial(shot: System, event: Event) -> None:
    # Before resolving: append current ball.state to ball.history
    # for each ball involved in the event.
    # This records the state just before the collision.
    for agent in event.agents:
        if isinstance(agent, Ball):
            agent.history.add(copy(agent.state))

# resolver.py:224
def _snapshot_final(shot: System, event: Event) -> None:
    # After resolving: append updated ball.state
    # This records the state just after the collision.
    # Together, the two snapshots bracket the instantaneous collision.
    for agent in event.agents:
        if isinstance(agent, Ball):
            agent.history.add(copy(agent.state))
Two states at the same timestamp bracket every collision in ball.history — one before and one after. The interpolation in continuize() treats these as an instantaneous discontinuity.