Event Detection

Finding the exact moment of the next physical event — before it happens.

How detection works

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.

EventDetector.get_next_event() — all detectors in parallel
# 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))

Priority tiebreaking

detector.py:30

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.

Tier 1 — highest priority
STICK_BALL — The cue strike at t=0. Always processed first so the ball starts moving before anything else can be detected.
Tier 2
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.
Tier 3 — lowest priority
BALL_BALL, BALL_LINEAR_CUSHION, BALL_CIRCULAR_CUSHION, BALL_TABLE. Physical collisions — resolved after the state is accurate.

Individual detectors

Stick–Ball

Only fires at t=0 when V0 > 0 and the cue ball is stationary. Produces a single STICK_BALL event at time 0.

Ball–Ball

Solves a quartic equation for each pair to find their earliest contact time. Returns the pair with the smallest positive root.

Ball–Linear Cushion

Each straight rail is treated as a vertical plane. Finds the time the ball center is exactly one radius from the plane.

Ball–Circular Cushion

Corner and side-pocket curved rails. The ball collides when the center-to-center distance equals the sum of the two radii.

Ball–Pocket

Ball enters a pocket when its center crosses within the pocket radius. Checked for all ball–pocket pairs.

Ball–Table (3D)

Only active when is_3d=True. Detects an airborne ball landing back on the table surface.

Ball–ball collision time — quartic solve

detect/ball_ball.py:19

ball_ball_collision_time() — the math
# 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)
    ...
The quartic is solved numerically using companion-matrix eigenvalues. This is exact (modulo floating point) — no root-finding iteration needed.

Ball–cushion collision time — plane intersection

detect/ball_cushion.py:23

ball_vertical_plane_collision_time() — signed distance to rail
# 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:
    ...

Transition detection — when does a state end?

Motion-state transitions (e.g. sliding → rolling) are detected analytically by solving for the time the relevant condition becomes zero.

TransitionCache._next_transition() — per-ball transition time
# 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, ...)
cache.py — transitions are cached per-ball and invalidated only when a ball is involved in a collision.