Finding the exact moment of the next physical event — before it happens.
On every simulation step, EventDetector.get_next_event() computes the predicted future collision or transition time for every possible event category. The earliest one wins. Tie-breaking uses a fixed priority order — transitions beat collisions at the same time.
# pooltool/evolution/event_based/detect/detector.py:123 def get_next_event(self, shot: System) -> Event: candidates: list[Event] = [] # ── Highest priority ────────────────────────────── candidates.append(get_next_stick_ball_event(shot)) # line 149 # ── Transitions (cached) ────────────────────────── for ball in shot.balls.values(): candidates.append(self.transition_cache.get_next(ball)) # line 151 # ── Collision detectors ─────────────────────────── candidates.append(get_next_ball_linear_cushion_event(shot)) # line 152 candidates.append(get_next_ball_circular_cushion_event(shot))# line 153 candidates.append(get_next_ball_pocket_event(shot)) # line 154 candidates.append(get_next_ball_ball_2d_event(shot)) # line 160 if self.is_3d: candidates.append(get_next_ball_table_event(shot)) # ── Pick earliest by time + priority tier ───────── return min(candidates, key=lambda e: _get_event_priority(e, shot))
When two events have the same predicted time (floating-point ties are common), the engine must pick one. The rule is a two-level key: first by time, then by tier.
STICK_BALL — The cue strike at t=0. Always processed first so the ball starts moving before anything else can be detected.BALL_POCKET and all motion-state transitions (SLIDING_ROLLING, ROLLING_SPINNING, ROLLING_STATIONARY, SPINNING_STATIONARY). Transitions change the physics equations, so they must precede any collision that was computed with the old trajectory.BALL_BALL, BALL_LINEAR_CUSHION, BALL_CIRCULAR_CUSHION, BALL_TABLE. Physical collisions — resolved after the state is accurate.Only fires at t=0 when V0 > 0 and the cue ball is stationary. Produces a single STICK_BALL event at time 0.
Solves a quartic equation for each pair to find their earliest contact time. Returns the pair with the smallest positive root.
Each straight rail is treated as a vertical plane. Finds the time the ball center is exactly one radius from the plane.
Corner and side-pocket curved rails. The ball collides when the center-to-center distance equals the sum of the two radii.
Ball enters a pocket when its center crosses within the pocket radius. Checked for all ball–pocket pairs.
Only active when is_3d=True. Detects an airborne ball landing back on the table surface.
# The condition for two sliding/rolling balls to touch is: # |r1(t) - r2(t)|² = (R1 + R2)² # # Where r(t) is the position of each ball as a function of time. # For sliding balls r(t) is quadratic in t (constant acceleration), # so |Δr(t)|² is a degree-4 polynomial → quartic equation. # # The function finds the smallest positive real root. # Returns inf if no future collision exists. @nb.jit(nopython=True, cache=True) def ball_ball_collision_time( rvw1, rvw2, s1, s2, mu1, mu2, m1, m2, g, R ) -> float: # Build polynomial coefficients from trajectory equations # Solve quartic for t > 0 # Return smallest positive root (or inf) ...
# Linear cushion: modeled as an infinite vertical plane at distance d # from the table center along normal n̂. # # Signed distance: φ(t) = n̂ · r(t) - d # Collision when: φ(t) = R (ball surface touches rail face) # # r(t) is quadratic for sliding, linear for rolling → # φ(t) = R is a quadratic (sliding) or linear (rolling) equation. # Take smallest positive root. @nb.jit(nopython=True, cache=True) def ball_vertical_plane_collision_time(rvw, s, lx, ly, l0, mu, m, g, R) -> float: ...
Motion-state transitions (e.g. sliding → rolling) are detected analytically by solving for the time the relevant condition becomes zero.
# pooltool/evolution/event_based/cache.py:73 def _next_transition(self, ball: Ball) -> Event: s = ball.state.s if s == sliding: # Time when |v - R×w_perp| → 0 (slip velocity → 0) t = _sliding_to_rolling_time(ball.state.rvw, ...) return Event(SLIDING_ROLLING, time=t, agents=[ball]) if s == rolling: # Time when |v| → 0 t_stop = _rolling_to_stationary_time(ball.state.rvw, ...) # Time when spin decouples from velocity (perpendicular spin remains) t_spin = _rolling_to_spinning_time(ball.state.rvw, ...) if t_spin < t_stop: return Event(ROLLING_SPINNING, time=t_spin, ...) return Event(ROLLING_STATIONARY, time=t_stop, ...) if s == spinning: # Time when |w_perp| → 0 t = _spinning_to_stationary_time(ball.state.rvw, ...) return Event(SPINNING_STATIONARY, time=t, ...)