Event-based time-stepping — advancing physics exactly to each collision or state transition, no fixed timestep approximations.
Fixed-timestep (Euler/RK4) methods accumulate error at each step. Because billiard physics are analytically solvable between events, pooltool finds the exact next event time, jumps there, then applies the collision model — zero error accumulation between events.
pooltool/evolution/event_based/simulate.py:103
EventDetector.get_next_event() queries all event categories and returns the soonest one (broken by priority tier).evolve_ball_motion() is called for every non-stationary ball, analytically advancing rvw by dt = event.time − system.t.Resolver.resolve(event) applies the collision or transition model, updating the agents' BallState.ball.history. The event is appended to system.events. Caches are invalidated.t_final is reached).def simulate( shot: System, engine: SimulationEngine | None = None, inplace: bool = False, continuous: bool = False, t_final: float | None = None, include: set[EventType] = INCLUDE_ALL, ) -> System:
| Parameter | Type | Description |
|---|---|---|
| shot | System | The system to simulate. Copied unless inplace=True. |
| engine | SimulationEngine | None | Physics engine bundle. Created with defaults if not given. |
| inplace | bool | Mutate shot directly instead of copying. Cheaper for single-use. |
| continuous | bool | If True, call continuize() automatically after simulation. |
| t_final | float | None | Stop simulation early at this time (seconds). |
| include | set[EventType] | Restrict which event types are resolved (others are still detected but skipped). |
# pooltool/evolution/event_based/simulate.py:21 class _SimulationState: shot: System engine: SimulationEngine def init(self): # line 38 # Reset all ball histories # Record initial null event at t=0 ... def step(self) -> Event: # line 42 event = self.engine.detector.get_next_event(self.shot) self._evolve(self.shot, event.time - self.shot.t) self.engine.resolver.resolve(event, self.shot) self.shot._update_history(event) self.update_caches() return event @staticmethod def evolve(shot: System, dt: float): # line 80 for ball in shot.balls.values(): if ball.state.s != stationary and ball.state.s != pocketed: ball.state.rvw, ball.state.s = evolve_ball_motion( state=ball.state.s, rvw=ball.state.rvw, R=ball.params.R, m=ball.params.m, u_s=ball.params.u_s, u_sp=ball.params.u_sp, u_r=ball.params.u_r, g=g, t=dt, )
pooltool/evolution/engine.py:12
# pooltool/evolution/engine.py:12 @dataclass class SimulationEngine: detector: EventDetector # finds the next event resolver: Resolver # applies the physics model is_3d: bool # enable airborne-ball support # Default engine (used when engine=None in simulate()): engine = SimulationEngine.default() # Equivalent to: engine = SimulationEngine( detector=EventDetector(is_3d=False), resolver=default_resolver(), is_3d=False, )
The engine is designed to be swapped. You can pass a custom engine to simulate() to use different collision models (e.g. Mathavan instead of FrictionalInelastic for ball-ball).
pooltool/physics/evolve/__init__.py:28
This Numba-compiled function takes the current rvw and motion state, advances them analytically by time t, and returns the new (rvw, state). No numerical integration — the equations have closed-form solutions.
@nb.jit(nopython=True, cache=True) def evolve_ball_motion(state, rvw, R, m, u_s, u_sp, u_r, g, t): # pooltool/physics/evolve/__init__.py:28 if state == stationary or state == pocketed: return rvw, state if state == sliding: return _evolve_slide_state(rvw, R, m, u_s, u_sp, g, t) # line 88 if state == rolling: return _evolve_roll_state(rvw, R, m, u_r, u_sp, g, t) # line 133 if state == spinning: return _evolve_perpendicular_spin_state(rvw, R, m, u_sp, g, t) # line 184 if state == airborne: return _evolve_airborne_state(rvw, g, t) # line 192
# pooltool/physics/evolve/__init__.py:88 def _evolve_slide_state(rvw, R, m, u_s, u_sp, g, t): # Sliding: surface velocity ≠ ball velocity # Kinetic (sliding) friction decelerates the center-of-mass # and also applies a torque changing angular velocity. # # v(t) = v0 + a_slide * t (linear) # w(t) = w0 + alpha_slide * t (angular) # # Transition to ROLLING when |v - R×w| → 0 ...
# pooltool/physics/evolve/__init__.py:133 def _evolve_roll_state(rvw, R, m, u_r, u_sp, g, t): # Rolling: no slip between ball and cloth # Only rolling friction (u_r ≪ u_s) slows the ball. # The spin vector stays aligned with velocity. # # v(t) = v0 - u_r * g * t (decelerates) # w(t) = v(t) / R (no-slip constraint) # # Transition to SPINNING when forward speed → 0 but spin remains, # or to STATIONARY when both are zero. ...
# pooltool/physics/evolve/__init__.py:184 def _evolve_perpendicular_spin_state(rvw, R, m, u_sp, g, t): # Ball has zero linear velocity but non-zero angular velocity # around the vertical axis (e.g. from stun or massé shot). # Spinning friction (u_sp) decays it to zero. ...
t=0.000s STICK_BALL → ball enters SLIDING (high slip, both v and w change) t=0.012s SLIDING→ROLLING → slip ratio = 0, pure roll begins t=0.290s BALL_LINEAR_CUSHION → cushion collision, ball re-enters SLIDING t=0.308s SLIDING→ROLLING t=1.100s ROLLING→STATIONARY → ball stops
SLIDING because the bounce changes the velocity/spin ratio.pooltool/evolution/event_based/cache.py
# pooltool/evolution/event_based/cache.py:30 # # For each ball, the cache stores the time of its next # motion-state transition (SLIDING→ROLLING, etc.). # # Transition times depend only on current ball state, so # they only need recomputing when a ball is touched by a collision. # All other balls can reuse their cached value. class TransitionCache: def get_next(self, ball: Ball) -> Event: if ball.id not in self._cache: self._cache[ball.id] = self._next_transition(ball) # line 73 return self._cache[ball.id] def invalidate(self, ball_id: str) -> None: self._cache.pop(ball_id, None)