Densifying the sparse event-log into a smooth animation, then rendering with Panda3D.
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.
pooltool/evolution/continuous.py:17
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.
# 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)
# 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), )
pooltool/objects/ball/render.py
# 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.
# 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
# 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)
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 ...