Visualization

Densifying the sparse event-log into a smooth animation, then rendering with Panda3D.

Sparse vs dense trajectory

Simulation stores one BallState per event — efficient for physics, but useless for animation. continuize() samples the analytic trajectory at a fixed timestep dt, producing the dense history_cts used by the renderer.

history
14 states at event times (irregular spacing)
ball.history — produced by simulate()
history_cts
350 states at dt=0.01s (uniform spacing)
ball.history_cts — produced by continuize()

continuize() — trajectory interpolation

pooltool/evolution/continuous.py:17

continuize() — function signature and algorithm
def continuize(
    system:  System,
    dt:      float = 0.01,   # sampling interval in seconds
    inplace: bool  = False,
) -> System:

For each ball, the algorithm walks the event history and fills gaps:

# For each ball b:
#   For each consecutive event pair (t_i, t_{i+1}):
#     Generate timestamps: t_i, t_i+dt, t_i+2dt, ..., t_{i+1}
#     For each timestamp t:
#       Call interpolate_ball_states(b.history.states[i], dt=t-t_i, ...)
#       Append result to b.history_cts
#
# The interpolation uses the same analytic equations as the simulator:
# evolve_ball_motion() — so the dense trajectory is exact, not
# a linear blend between keyframes.
interpolate_ball_states() — re-using the physics equations
# pooltool/evolution/continuous.py:194
def interpolate_ball_states(
    state:   BallState,
    dt:      float,
    ball:    Ball,
) -> BallState:
    # Advance `state` by exactly `dt` seconds using evolve_ball_motion().
    # Returns the new BallState without mutating the input.
    #
    # Because the analytic equations are used, the interpolated position
    # is numerically identical to what the simulator would compute
    # if it had stepped to that exact time.
    new_rvw, new_s = evolve_ball_motion(
        state=state.s, rvw=state.rvw.copy(), ..., t=dt
    )
    return BallState(rvw=new_rvw, s=new_s, t=state.t + dt)

SystemRender — scene graph builder

pooltool/system/render.py

SystemRender.from_system() — constructing the scene
# pooltool/system/render.py
class SystemRender:
    ball_renders:  dict[str, BallRender]
    table_render:  TableRender
    cue_render:    CueRender

    @classmethod
    def from_system(cls, system: System) -> "SystemRender":
        # Auto-continuize if history_cts is empty
        if not system.balls["cue"].history_cts.states:
            continuize(system, inplace=True)

        return cls(
            ball_renders = {
                bid: BallRender.from_ball(ball)
                for bid, ball in system.balls.items()
            },
            table_render = TableRender.from_table(system.table),
            cue_render   = CueRender.from_cue(system.cue),
        )

BallRender — 3D sphere with texture and shadow

pooltool/objects/ball/render.py

BallRender — Panda3D node setup
# pooltool/objects/ball/render.py:47 (init_sphere)
#
# Each ball is a Panda3D sphere node:
#   - Geometry: 16×16 sphere tessellation
#   - Texture:  loaded from ball.ballset (PNG atlas by ball number)
#   - Shadow:   flat ellipse projected below ball using height-dependent
#               scale (vendored from 3d branch in #339)
#
# Per-frame update during animation:
#   node.setPos(rvw[0, 0], rvw[0, 1], rvw[0, 2])   # position
#   node.setQuat(quat)                               # rotation from BallOrientation
#
# BallOrientation stores a quaternion that is updated by integrating
# angular velocity ω over each frame's dt.

ShotViewer — interactive playback

pooltool/ani/animate.py:327

ShotViewer — Panda3D ShowBase with animation loop
# pooltool/ani/animate.py:327
class ShotViewer(Interface):
    """Visualization-only mode: plays back a simulated shot."""

    # Inherits Panda3D ShowBase.  Key tasks added:
    #   taskMgr.add(self._animate_task, "animate")
    #
    # _animate_task each frame:
    #   frame_idx = int(elapsed / dt)
    #   for ball in balls:
    #       state = ball.history_cts.states[frame_idx]
    #       render_node.setPos(state.rvw[0])
    #       render_node.setQuat(state.orientation)
    #
    # Controls:
    #   ESC       — quit
    #   Space     — pause / resume
    #   n / p     — next / previous shot (MultiSystem)
    #   scroll    — camera zoom
    #   drag      — camera orbit
Game — full interactive play mode
# pooltool/ani/animate.py:469
class Game(Interface):
    """Interactive billiards game using the same Panda3D base."""

    # Modes / states the Game can be in:
    #   aim        — player rotates cue angle with mouse
    #   stroke     — player drags cue to set V0, then releases
    #   calculate  — simulate() runs asynchronously
    #   view       — ShotViewer playback of the result
    #   ball_in_hand — player places a ball (e.g. after scratch)
    #
    # Invoked via CLI: run-pooltool  (pooltool/main.py)
interact.py:8show() entry point  ·  main.py — CLI entry point

show() — public entry point

pooltool/interact.py:8

show() — accepts System or MultiSystem
def show(*args, **kwargs):
    # pooltool/interact.py:8
    #
    # Accepts:
    #   show(system)             — single shot viewer
    #   show(multisystem)        — multi-shot viewer (n/p to navigate)
    #   show(system, game=True)  — interactive game mode
    #
    # Internally:
    #   1. Auto-continuize any system that lacks history_cts
    #   2. Construct ShotViewer (or Game)
    #   3. Call ShowBase.run() → blocks until window is closed
    ...