Applying collision models to update ball velocities and spin after each detected event.
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.
# 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)
pocketed and zeroes velocity. Simple removal.rvw to exactly satisfy the transition condition.pooltool/physics/resolve/stick_ball/
# 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).
pooltool/physics/resolve/ball_ball/core.py
# 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.
pooltool/physics/resolve/ball_cushion/
# 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.
# 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]
# 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))
ball.history — one before and one after. The interpolation in continuize() treats these as an instantaneous discontinuity.