From dc69bf89df80b2ab8215d0de24fff23fd21b051c Mon Sep 17 00:00:00 2001 From: Evan Kiefl Date: Mon, 18 May 2026 00:22:39 -0700 Subject: [PATCH 1/4] Introduce stick-ball 3D resolver --- pooltool/physics/resolve/models.py | 35 ++++++++++++------ pooltool/physics/resolve/resolver.py | 6 +-- .../physics/resolve/stick_ball/__init__.py | 8 +++- pooltool/physics/resolve/stick_ball/core.py | 19 ++++++++++ .../instantaneous_point/__init__.py | 37 ++++++++++++++----- 5 files changed, 79 insertions(+), 26 deletions(-) diff --git a/pooltool/physics/resolve/models.py b/pooltool/physics/resolve/models.py index 65cf5aa2..4eb3174d 100644 --- a/pooltool/physics/resolve/models.py +++ b/pooltool/physics/resolve/models.py @@ -141,21 +141,32 @@ class BallPocketModel(StrEnum): class StickBallModel(StrEnum): """An Enum for different stick-ball collision models - Attributes: - INSTANTANEOUS_POINT: - Instantaneous and point-like stick-ball interaction. - - This collision assumes the stick-ball interaction is instantaneous and point-like. + The underlying math (see :func:`cue_strike`) is fully 3D: it produces a + ball velocity with a vertical component proportional to ``sin(theta)``, + the cue elevation. The two members below differ only in what they do with + that vertical component. - Note: - - A derivation of this model can be found in Dr. Dave Billiard's technical proof - A-30 (https://billiards.colostate.edu/technical_proofs/new/TP_A-30.pdf) - - Additionally, a deflection (squirt) angle is calculated via - :mod:`pooltool.physics.resolve.stick_ball.squirt`). + Attributes: + INSTANTANEOUS_POINT_3D: + Instantaneous and point-like stick-ball interaction. The full 3D + cue-strike result is applied to the ball, including any vertical + velocity produced by cue elevation. Suitable for a 3D simulation + that supports airborne motion. + + A derivation can be found in Dr. Dave Billiard's technical proof + A-30 (https://billiards.colostate.edu/technical_proofs/new/TP_A-30.pdf). + A deflection (squirt) angle is calculated via + :mod:`pooltool.physics.resolve.stick_ball.squirt`. + + INSTANTANEOUS_POINT_2D: + Same 3D cue-strike math as ``INSTANTANEOUS_POINT_3D``, followed by + a clamp that zeros the vertical velocity component of the ball. + Suitable for a 2D simulation: the ball never leaves the table + surface regardless of cue elevation. """ - INSTANTANEOUS_POINT = auto() + INSTANTANEOUS_POINT_2D = auto() + INSTANTANEOUS_POINT_3D = auto() class BallTableModel(StrEnum): diff --git a/pooltool/physics/resolve/resolver.py b/pooltool/physics/resolve/resolver.py index cb1812fb..b07847b2 100644 --- a/pooltool/physics/resolve/resolver.py +++ b/pooltool/physics/resolve/resolver.py @@ -38,7 +38,7 @@ from pooltool.physics.resolve.stick_ball import ( StickBallCollisionStrategy, ) -from pooltool.physics.resolve.stick_ball.instantaneous_point import InstantaneousPoint +from pooltool.physics.resolve.stick_ball.instantaneous_point import InstantaneousPoint2D from pooltool.physics.resolve.transition import ( BallTransitionStrategy, CanonicalTransition, @@ -50,7 +50,7 @@ RESOLVER_PATH = pooltool.config.paths.PHYSICS_DIR / "resolver.yaml" """The location of the resolver path YAML.""" -VERSION: int = 10 +VERSION: int = 11 run = Run() @@ -82,7 +82,7 @@ def default_resolver() -> Resolver: omega_ratio=1.8, ), ball_pocket=CanonicalBallPocket(), - stick_ball=InstantaneousPoint( + stick_ball=InstantaneousPoint2D( english_throttle=1.0, squirt_throttle=1.0, ), diff --git a/pooltool/physics/resolve/stick_ball/__init__.py b/pooltool/physics/resolve/stick_ball/__init__.py index 7f7e831e..e45c46ca 100644 --- a/pooltool/physics/resolve/stick_ball/__init__.py +++ b/pooltool/physics/resolve/stick_ball/__init__.py @@ -4,10 +4,14 @@ from pooltool.physics.resolve.models import StickBallModel from pooltool.physics.resolve.stick_ball.core import StickBallCollisionStrategy -from pooltool.physics.resolve.stick_ball.instantaneous_point import InstantaneousPoint +from pooltool.physics.resolve.stick_ball.instantaneous_point import ( + InstantaneousPoint2D, + InstantaneousPoint3D, +) _stick_ball_model_registry: tuple[type[StickBallCollisionStrategy], ...] = ( - InstantaneousPoint, + InstantaneousPoint2D, + InstantaneousPoint3D, ) stick_ball_models: dict[StickBallModel, type[StickBallCollisionStrategy]] = { diff --git a/pooltool/physics/resolve/stick_ball/core.py b/pooltool/physics/resolve/stick_ball/core.py index c2af34da..fa4537cc 100644 --- a/pooltool/physics/resolve/stick_ball/core.py +++ b/pooltool/physics/resolve/stick_ball/core.py @@ -1,11 +1,30 @@ from abc import ABC, abstractmethod from typing import Protocol +import numpy as np +from numpy.typing import NDArray + +import pooltool.constants as const from pooltool.objects.ball.datatypes import Ball from pooltool.objects.cue.datatypes import Cue from pooltool.physics.dimensionality import Dim +def final_ball_motion_state(rvw: NDArray[np.float64], R: float) -> int: + """Return the final (post-strike) motion state label. + + If the z-velocity is non-zero the ball is considered airborne, otherwise + it is sliding (a struck ball is always kinetic). + + Notes: + - A universal ``final_ball_motion_state`` fn could be a good idea. + """ + if rvw[1, 2] != 0.0: + return const.airborne + + return const.sliding + + class _BaseStrategy(Protocol): def resolve( self, cue: Cue, ball: Ball, inplace: bool = False diff --git a/pooltool/physics/resolve/stick_ball/instantaneous_point/__init__.py b/pooltool/physics/resolve/stick_ball/instantaneous_point/__init__.py index f940c7e7..db227a49 100644 --- a/pooltool/physics/resolve/stick_ball/instantaneous_point/__init__.py +++ b/pooltool/physics/resolve/stick_ball/instantaneous_point/__init__.py @@ -1,13 +1,15 @@ import attrs import numpy as np -import pooltool.constants as const import pooltool.ptmath as ptmath from pooltool.objects.ball.datatypes import Ball, BallState from pooltool.objects.cue.datatypes import Cue from pooltool.physics.dimensionality import Dim from pooltool.physics.resolve.models import StickBallModel -from pooltool.physics.resolve.stick_ball.core import CoreStickBallCollision +from pooltool.physics.resolve.stick_ball.core import ( + CoreStickBallCollision, + final_ball_motion_state, +) from pooltool.physics.resolve.stick_ball.squirt import get_squirt_angle from pooltool.ptmath.utils import coordinate_rotation @@ -85,9 +87,7 @@ def cue_strike(m, M, R, V0, phi, theta, Q): denominator = 1 + m / M + temp / I_m v = numerator / denominator - # 3D FIXME - # v_B = -v * np.array([0, np.cos(theta), np.sin(theta)]) - v_B = -v * np.array([0, np.cos(theta), 0]) + v_B = -v * np.array([0, np.cos(theta), np.sin(theta)]) vec_x = -c * np.sin(theta) + b * np.cos(theta) vec_y = a * np.sin(theta) @@ -105,7 +105,7 @@ def cue_strike(m, M, R, V0, phi, theta, Q): @attrs.define -class InstantaneousPoint(CoreStickBallCollision): +class InstantaneousPoint3D(CoreStickBallCollision): """Instantaneous and point-like stick-ball interaction This collision assumes the stick-ball interaction is instantaneous and point-like. @@ -128,9 +128,9 @@ class InstantaneousPoint(CoreStickBallCollision): squirt_throttle: float = 1.0 model: StickBallModel = attrs.field( - default=StickBallModel.INSTANTANEOUS_POINT, init=False, repr=False + default=StickBallModel.INSTANTANEOUS_POINT_3D, init=False, repr=False ) - dim: Dim = attrs.field(default=Dim.TWO, init=False, repr=False) + dim: Dim = attrs.field(default=Dim.THREE, init=False, repr=False) def solve(self, cue: Cue, ball: Ball) -> tuple[Cue, Ball]: # Transform contact point Q from cue frame to ball frame @@ -163,8 +163,27 @@ def solve(self, cue: Cue, ball: Ball) -> tuple[Cue, Ball]: v = coordinate_rotation(v, alpha) rvw = np.array([ball.state.rvw[0], v, w * self.english_throttle]) - s = const.sliding + s = final_ball_motion_state(rvw, ball.params.R) ball.state = BallState(rvw, s) return cue, ball + + +@attrs.define +class InstantaneousPoint2D(InstantaneousPoint3D): + """Instantaneous and point-like stick-ball interaction (2D) + + For details see :class:`InstantaneousPoint3D`. + """ + + model: StickBallModel = attrs.field( + default=StickBallModel.INSTANTANEOUS_POINT_2D, init=False, repr=False + ) + dim: Dim = attrs.field(default=Dim.BOTH, init=False, repr=False) + + def solve(self, cue: Cue, ball: Ball) -> tuple[Cue, Ball]: + cue, ball = super().solve(cue, ball) + ball.state.rvw[1, 2] = 0.0 + ball.state.s = final_ball_motion_state(ball.state.rvw, ball.params.R) + return cue, ball From 591b12e5c3444094df6280db968139ef09a9d33c Mon Sep 17 00:00:00 2001 From: Evan Kiefl Date: Mon, 18 May 2026 00:30:06 -0700 Subject: [PATCH 2/4] Dumb down mode-based dispatching for now - Easier to playtest partial 3D functionality --- .../evolution/event_based/detect/__init__.py | 36 ++++------- .../evolution/event_based/detect/ball_ball.py | 8 +-- .../event_based/detect/ball_cushion.py | 24 ++------ .../event_based/detect/ball_pocket.py | 12 +--- .../evolution/event_based/detect/detector.py | 60 ++++++------------- tests/evolution/event_based/test_simulate.py | 4 +- 6 files changed, 38 insertions(+), 106 deletions(-) diff --git a/pooltool/evolution/event_based/detect/__init__.py b/pooltool/evolution/event_based/detect/__init__.py index c645826a..82f01bea 100644 --- a/pooltool/evolution/event_based/detect/__init__.py +++ b/pooltool/evolution/event_based/detect/__init__.py @@ -1,35 +1,19 @@ -from pooltool.evolution.event_based.detect.ball_ball import ( - get_next_ball_ball_2d_event, - get_next_ball_ball_3d_event, -) +from pooltool.evolution.event_based.detect.ball_ball import get_next_ball_ball_event from pooltool.evolution.event_based.detect.ball_cushion import ( - get_next_ball_circular_cushion_2d_event, - get_next_ball_circular_cushion_3d_event, - get_next_ball_linear_cushion_2d_event, - get_next_ball_linear_cushion_3d_event, -) -from pooltool.evolution.event_based.detect.ball_pocket import ( - get_next_ball_pocket_2d_event, - get_next_ball_pocket_3d_event, -) -from pooltool.evolution.event_based.detect.ball_table import ( - get_next_ball_table_event, + get_next_ball_circular_cushion_event, + get_next_ball_linear_cushion_event, ) +from pooltool.evolution.event_based.detect.ball_pocket import get_next_ball_pocket_event +from pooltool.evolution.event_based.detect.ball_table import get_next_ball_table_event from pooltool.evolution.event_based.detect.detector import EventDetector -from pooltool.evolution.event_based.detect.stick_ball import ( - get_next_stick_ball_event, -) +from pooltool.evolution.event_based.detect.stick_ball import get_next_stick_ball_event __all__ = [ "EventDetector", - "get_next_ball_ball_2d_event", - "get_next_ball_ball_3d_event", - "get_next_ball_circular_cushion_2d_event", - "get_next_ball_circular_cushion_3d_event", - "get_next_ball_linear_cushion_2d_event", - "get_next_ball_linear_cushion_3d_event", - "get_next_ball_pocket_2d_event", - "get_next_ball_pocket_3d_event", + "get_next_ball_ball_event", + "get_next_ball_circular_cushion_event", + "get_next_ball_linear_cushion_event", + "get_next_ball_pocket_event", "get_next_ball_table_event", "get_next_stick_ball_event", ] diff --git a/pooltool/evolution/event_based/detect/ball_ball.py b/pooltool/evolution/event_based/detect/ball_ball.py index 2c3c550b..7830f271 100644 --- a/pooltool/evolution/event_based/detect/ball_ball.py +++ b/pooltool/evolution/event_based/detect/ball_ball.py @@ -12,8 +12,8 @@ from pooltool.system.datatypes import System -def get_next_ball_ball_2d_event(shot: System, collision_cache: CollisionCache) -> Event: - """Detect the next ball-ball collision in 2D mode.""" +def get_next_ball_ball_event(shot: System, collision_cache: CollisionCache) -> Event: + """Detect the next ball-ball collision.""" cache = collision_cache.times.setdefault(EventType.BALL_BALL, {}) for ball1, ball2 in combinations(shot.balls.values(), 2): @@ -75,7 +75,3 @@ def get_next_ball_ball_2d_event(shot: System, collision_cache: CollisionCache) - ball2=shot.balls[ball_pair[1]], time=cache[ball_pair], ) - - -def get_next_ball_ball_3d_event(shot: System, collision_cache: CollisionCache) -> Event: - raise NotImplementedError("3D ball-ball detection has not been vendored yet") diff --git a/pooltool/evolution/event_based/detect/ball_cushion.py b/pooltool/evolution/event_based/detect/ball_cushion.py index d67cbc65..13ce5402 100644 --- a/pooltool/evolution/event_based/detect/ball_cushion.py +++ b/pooltool/evolution/event_based/detect/ball_cushion.py @@ -18,10 +18,10 @@ from pooltool.system.datatypes import System -def get_next_ball_linear_cushion_2d_event( +def get_next_ball_linear_cushion_event( shot: System, collision_cache: CollisionCache ) -> Event: - """Detect the next ball-vs-linear-cushion collision in 2D mode.""" + """Detect the next ball-vs-linear-cushion collision.""" if not shot.table.has_linear_cushions: return null_event(np.inf) @@ -67,18 +67,10 @@ def get_next_ball_linear_cushion_2d_event( ) -def get_next_ball_linear_cushion_3d_event( +def get_next_ball_circular_cushion_event( shot: System, collision_cache: CollisionCache ) -> Event: - raise NotImplementedError( - "3D ball-linear-cushion detection has not been vendored yet" - ) - - -def get_next_ball_circular_cushion_2d_event( - shot: System, collision_cache: CollisionCache -) -> Event: - """Detect the next ball-vs-circular-cushion collision in 2D mode.""" + """Detect the next ball-vs-circular-cushion collision.""" if not shot.table.has_circular_cushions: return null_event(np.inf) @@ -118,11 +110,3 @@ def get_next_ball_circular_cushion_2d_event( cushion=shot.table.cushion_segments.circular[cushion_id], time=cache[(ball_id, cushion_id)], ) - - -def get_next_ball_circular_cushion_3d_event( - shot: System, collision_cache: CollisionCache -) -> Event: - raise NotImplementedError( - "3D ball-circular-cushion detection has not been vendored yet" - ) diff --git a/pooltool/evolution/event_based/detect/ball_pocket.py b/pooltool/evolution/event_based/detect/ball_pocket.py index 1424f873..99d5f4de 100644 --- a/pooltool/evolution/event_based/detect/ball_pocket.py +++ b/pooltool/evolution/event_based/detect/ball_pocket.py @@ -9,10 +9,8 @@ from pooltool.system.datatypes import System -def get_next_ball_pocket_2d_event( - shot: System, collision_cache: CollisionCache -) -> Event: - """Detect the next ball-pocket collision in 2D mode.""" +def get_next_ball_pocket_event(shot: System, collision_cache: CollisionCache) -> Event: + """Detect the next ball-pocket collision.""" if not shot.table.has_pockets: return null_event(np.inf) @@ -52,9 +50,3 @@ def get_next_ball_pocket_2d_event( pocket=shot.table.pockets[pocket_id], time=cache[(ball_id, pocket_id)], ) - - -def get_next_ball_pocket_3d_event( - shot: System, collision_cache: CollisionCache -) -> Event: - raise NotImplementedError("3D ball-pocket detection has not been vendored yet") diff --git a/pooltool/evolution/event_based/detect/detector.py b/pooltool/evolution/event_based/detect/detector.py index 57f0e655..ccaa2e16 100644 --- a/pooltool/evolution/event_based/detect/detector.py +++ b/pooltool/evolution/event_based/detect/detector.py @@ -6,26 +6,14 @@ import pooltool.ptmath as ptmath from pooltool.events import Event, EventType, null_event from pooltool.evolution.event_based.cache import CollisionCache, TransitionCache -from pooltool.evolution.event_based.detect.ball_ball import ( - get_next_ball_ball_2d_event, - get_next_ball_ball_3d_event, -) +from pooltool.evolution.event_based.detect.ball_ball import get_next_ball_ball_event from pooltool.evolution.event_based.detect.ball_cushion import ( - get_next_ball_circular_cushion_2d_event, - get_next_ball_circular_cushion_3d_event, - get_next_ball_linear_cushion_2d_event, - get_next_ball_linear_cushion_3d_event, -) -from pooltool.evolution.event_based.detect.ball_pocket import ( - get_next_ball_pocket_2d_event, - get_next_ball_pocket_3d_event, -) -from pooltool.evolution.event_based.detect.ball_table import ( - get_next_ball_table_event, -) -from pooltool.evolution.event_based.detect.stick_ball import ( - get_next_stick_ball_event, + get_next_ball_circular_cushion_event, + get_next_ball_linear_cushion_event, ) +from pooltool.evolution.event_based.detect.ball_pocket import get_next_ball_pocket_event +from pooltool.evolution.event_based.detect.ball_table import get_next_ball_table_event +from pooltool.evolution.event_based.detect.stick_ball import get_next_stick_ball_event from pooltool.physics.utils import get_ball_energy from pooltool.system.datatypes import System @@ -100,13 +88,16 @@ def _get_event_priority(event: Event, shot: System) -> tuple[int, float]: class EventDetector: """Orchestrates per-event-type detection. - The 2D-vs-3D branching for forked event types happens here, in ``get_next_event``. - The per-event-type ``get_next_*_event`` functions are each mode-pure. + Detection isn't pluggable — there's one canonical algorithm per event type. + The class exists to bundle the per-event-type functions together and apply + priority-based tie-breaking when multiple events occur simultaneously. Attributes: is_3d: - Whether to dispatch to 3D detection variants. Set by ``SimulationEngine`` at - construction. + Whether the simulator is running in 3D mode. Currently inert + (the per-event-type functions don't yet fork on mode), but + present as the architectural anchor for future 2D/3D dispatch. + Synced from ``SimulationEngine.is_3d`` at engine construction. """ is_3d: bool = False @@ -144,26 +135,11 @@ def get_next_event( candidates.append(get_next_stick_ball_event(shot, collision_cache)) candidates.append(transition_cache.get_next()) - - if self.is_3d: - candidates.append(get_next_ball_ball_3d_event(shot, collision_cache)) - candidates.append( - get_next_ball_circular_cushion_3d_event(shot, collision_cache) - ) - candidates.append( - get_next_ball_linear_cushion_3d_event(shot, collision_cache) - ) - candidates.append(get_next_ball_pocket_3d_event(shot, collision_cache)) - candidates.append(get_next_ball_table_event(shot, collision_cache)) - else: - candidates.append(get_next_ball_ball_2d_event(shot, collision_cache)) - candidates.append( - get_next_ball_circular_cushion_2d_event(shot, collision_cache) - ) - candidates.append( - get_next_ball_linear_cushion_2d_event(shot, collision_cache) - ) - candidates.append(get_next_ball_pocket_2d_event(shot, collision_cache)) + candidates.append(get_next_ball_ball_event(shot, collision_cache)) + candidates.append(get_next_ball_circular_cushion_event(shot, collision_cache)) + candidates.append(get_next_ball_linear_cushion_event(shot, collision_cache)) + candidates.append(get_next_ball_pocket_event(shot, collision_cache)) + candidates.append(get_next_ball_table_event(shot, collision_cache)) min_time = min(event.time for event in candidates) diff --git a/tests/evolution/event_based/test_simulate.py b/tests/evolution/event_based/test_simulate.py index 3baf6bcb..7670ebf8 100644 --- a/tests/evolution/event_based/test_simulate.py +++ b/tests/evolution/event_based/test_simulate.py @@ -11,7 +11,7 @@ from pooltool.evolution.event_based.cache import CollisionCache from pooltool.evolution.event_based.detect import ( EventDetector, - get_next_ball_ball_2d_event, + get_next_ball_ball_event, ) from pooltool.evolution.event_based.simulate import simulate from pooltool.objects import Ball, BilliardTableSpecs, Cue, Table @@ -452,7 +452,7 @@ def test_ball_ball_collision_for_intersecting_balls(): _assert_rolling(system.balls["cue"].state.rvw, system.balls["cue"].params.R) assert _DETECTOR.get_next_event(system).event_type == EventType.BALL_BALL - collision_event = get_next_ball_ball_2d_event(system, CollisionCache()) + collision_event = get_next_ball_ball_event(system, CollisionCache()) assert collision_event.time != np.inf assert collision_event.time == 0 From 357bd935637e22d90bcf100005f7cacee503ae73 Mon Sep 17 00:00:00 2001 From: Evan Kiefl Date: Mon, 18 May 2026 00:35:12 -0700 Subject: [PATCH 3/4] Revert "Dumb down mode-based dispatching for now" This reverts commit 591b12e5c3444094df6280db968139ef09a9d33c. --- .../evolution/event_based/detect/__init__.py | 36 +++++++---- .../evolution/event_based/detect/ball_ball.py | 8 ++- .../event_based/detect/ball_cushion.py | 24 ++++++-- .../event_based/detect/ball_pocket.py | 12 +++- .../evolution/event_based/detect/detector.py | 60 +++++++++++++------ tests/evolution/event_based/test_simulate.py | 4 +- 6 files changed, 106 insertions(+), 38 deletions(-) diff --git a/pooltool/evolution/event_based/detect/__init__.py b/pooltool/evolution/event_based/detect/__init__.py index 82f01bea..c645826a 100644 --- a/pooltool/evolution/event_based/detect/__init__.py +++ b/pooltool/evolution/event_based/detect/__init__.py @@ -1,19 +1,35 @@ -from pooltool.evolution.event_based.detect.ball_ball import get_next_ball_ball_event +from pooltool.evolution.event_based.detect.ball_ball import ( + get_next_ball_ball_2d_event, + get_next_ball_ball_3d_event, +) from pooltool.evolution.event_based.detect.ball_cushion import ( - get_next_ball_circular_cushion_event, - get_next_ball_linear_cushion_event, + get_next_ball_circular_cushion_2d_event, + get_next_ball_circular_cushion_3d_event, + get_next_ball_linear_cushion_2d_event, + get_next_ball_linear_cushion_3d_event, +) +from pooltool.evolution.event_based.detect.ball_pocket import ( + get_next_ball_pocket_2d_event, + get_next_ball_pocket_3d_event, +) +from pooltool.evolution.event_based.detect.ball_table import ( + get_next_ball_table_event, ) -from pooltool.evolution.event_based.detect.ball_pocket import get_next_ball_pocket_event -from pooltool.evolution.event_based.detect.ball_table import get_next_ball_table_event from pooltool.evolution.event_based.detect.detector import EventDetector -from pooltool.evolution.event_based.detect.stick_ball import get_next_stick_ball_event +from pooltool.evolution.event_based.detect.stick_ball import ( + get_next_stick_ball_event, +) __all__ = [ "EventDetector", - "get_next_ball_ball_event", - "get_next_ball_circular_cushion_event", - "get_next_ball_linear_cushion_event", - "get_next_ball_pocket_event", + "get_next_ball_ball_2d_event", + "get_next_ball_ball_3d_event", + "get_next_ball_circular_cushion_2d_event", + "get_next_ball_circular_cushion_3d_event", + "get_next_ball_linear_cushion_2d_event", + "get_next_ball_linear_cushion_3d_event", + "get_next_ball_pocket_2d_event", + "get_next_ball_pocket_3d_event", "get_next_ball_table_event", "get_next_stick_ball_event", ] diff --git a/pooltool/evolution/event_based/detect/ball_ball.py b/pooltool/evolution/event_based/detect/ball_ball.py index 7830f271..2c3c550b 100644 --- a/pooltool/evolution/event_based/detect/ball_ball.py +++ b/pooltool/evolution/event_based/detect/ball_ball.py @@ -12,8 +12,8 @@ from pooltool.system.datatypes import System -def get_next_ball_ball_event(shot: System, collision_cache: CollisionCache) -> Event: - """Detect the next ball-ball collision.""" +def get_next_ball_ball_2d_event(shot: System, collision_cache: CollisionCache) -> Event: + """Detect the next ball-ball collision in 2D mode.""" cache = collision_cache.times.setdefault(EventType.BALL_BALL, {}) for ball1, ball2 in combinations(shot.balls.values(), 2): @@ -75,3 +75,7 @@ def get_next_ball_ball_event(shot: System, collision_cache: CollisionCache) -> E ball2=shot.balls[ball_pair[1]], time=cache[ball_pair], ) + + +def get_next_ball_ball_3d_event(shot: System, collision_cache: CollisionCache) -> Event: + raise NotImplementedError("3D ball-ball detection has not been vendored yet") diff --git a/pooltool/evolution/event_based/detect/ball_cushion.py b/pooltool/evolution/event_based/detect/ball_cushion.py index 13ce5402..d67cbc65 100644 --- a/pooltool/evolution/event_based/detect/ball_cushion.py +++ b/pooltool/evolution/event_based/detect/ball_cushion.py @@ -18,10 +18,10 @@ from pooltool.system.datatypes import System -def get_next_ball_linear_cushion_event( +def get_next_ball_linear_cushion_2d_event( shot: System, collision_cache: CollisionCache ) -> Event: - """Detect the next ball-vs-linear-cushion collision.""" + """Detect the next ball-vs-linear-cushion collision in 2D mode.""" if not shot.table.has_linear_cushions: return null_event(np.inf) @@ -67,10 +67,18 @@ def get_next_ball_linear_cushion_event( ) -def get_next_ball_circular_cushion_event( +def get_next_ball_linear_cushion_3d_event( shot: System, collision_cache: CollisionCache ) -> Event: - """Detect the next ball-vs-circular-cushion collision.""" + raise NotImplementedError( + "3D ball-linear-cushion detection has not been vendored yet" + ) + + +def get_next_ball_circular_cushion_2d_event( + shot: System, collision_cache: CollisionCache +) -> Event: + """Detect the next ball-vs-circular-cushion collision in 2D mode.""" if not shot.table.has_circular_cushions: return null_event(np.inf) @@ -110,3 +118,11 @@ def get_next_ball_circular_cushion_event( cushion=shot.table.cushion_segments.circular[cushion_id], time=cache[(ball_id, cushion_id)], ) + + +def get_next_ball_circular_cushion_3d_event( + shot: System, collision_cache: CollisionCache +) -> Event: + raise NotImplementedError( + "3D ball-circular-cushion detection has not been vendored yet" + ) diff --git a/pooltool/evolution/event_based/detect/ball_pocket.py b/pooltool/evolution/event_based/detect/ball_pocket.py index 99d5f4de..1424f873 100644 --- a/pooltool/evolution/event_based/detect/ball_pocket.py +++ b/pooltool/evolution/event_based/detect/ball_pocket.py @@ -9,8 +9,10 @@ from pooltool.system.datatypes import System -def get_next_ball_pocket_event(shot: System, collision_cache: CollisionCache) -> Event: - """Detect the next ball-pocket collision.""" +def get_next_ball_pocket_2d_event( + shot: System, collision_cache: CollisionCache +) -> Event: + """Detect the next ball-pocket collision in 2D mode.""" if not shot.table.has_pockets: return null_event(np.inf) @@ -50,3 +52,9 @@ def get_next_ball_pocket_event(shot: System, collision_cache: CollisionCache) -> pocket=shot.table.pockets[pocket_id], time=cache[(ball_id, pocket_id)], ) + + +def get_next_ball_pocket_3d_event( + shot: System, collision_cache: CollisionCache +) -> Event: + raise NotImplementedError("3D ball-pocket detection has not been vendored yet") diff --git a/pooltool/evolution/event_based/detect/detector.py b/pooltool/evolution/event_based/detect/detector.py index ccaa2e16..57f0e655 100644 --- a/pooltool/evolution/event_based/detect/detector.py +++ b/pooltool/evolution/event_based/detect/detector.py @@ -6,14 +6,26 @@ import pooltool.ptmath as ptmath from pooltool.events import Event, EventType, null_event from pooltool.evolution.event_based.cache import CollisionCache, TransitionCache -from pooltool.evolution.event_based.detect.ball_ball import get_next_ball_ball_event +from pooltool.evolution.event_based.detect.ball_ball import ( + get_next_ball_ball_2d_event, + get_next_ball_ball_3d_event, +) from pooltool.evolution.event_based.detect.ball_cushion import ( - get_next_ball_circular_cushion_event, - get_next_ball_linear_cushion_event, + get_next_ball_circular_cushion_2d_event, + get_next_ball_circular_cushion_3d_event, + get_next_ball_linear_cushion_2d_event, + get_next_ball_linear_cushion_3d_event, +) +from pooltool.evolution.event_based.detect.ball_pocket import ( + get_next_ball_pocket_2d_event, + get_next_ball_pocket_3d_event, +) +from pooltool.evolution.event_based.detect.ball_table import ( + get_next_ball_table_event, +) +from pooltool.evolution.event_based.detect.stick_ball import ( + get_next_stick_ball_event, ) -from pooltool.evolution.event_based.detect.ball_pocket import get_next_ball_pocket_event -from pooltool.evolution.event_based.detect.ball_table import get_next_ball_table_event -from pooltool.evolution.event_based.detect.stick_ball import get_next_stick_ball_event from pooltool.physics.utils import get_ball_energy from pooltool.system.datatypes import System @@ -88,16 +100,13 @@ def _get_event_priority(event: Event, shot: System) -> tuple[int, float]: class EventDetector: """Orchestrates per-event-type detection. - Detection isn't pluggable — there's one canonical algorithm per event type. - The class exists to bundle the per-event-type functions together and apply - priority-based tie-breaking when multiple events occur simultaneously. + The 2D-vs-3D branching for forked event types happens here, in ``get_next_event``. + The per-event-type ``get_next_*_event`` functions are each mode-pure. Attributes: is_3d: - Whether the simulator is running in 3D mode. Currently inert - (the per-event-type functions don't yet fork on mode), but - present as the architectural anchor for future 2D/3D dispatch. - Synced from ``SimulationEngine.is_3d`` at engine construction. + Whether to dispatch to 3D detection variants. Set by ``SimulationEngine`` at + construction. """ is_3d: bool = False @@ -135,11 +144,26 @@ def get_next_event( candidates.append(get_next_stick_ball_event(shot, collision_cache)) candidates.append(transition_cache.get_next()) - candidates.append(get_next_ball_ball_event(shot, collision_cache)) - candidates.append(get_next_ball_circular_cushion_event(shot, collision_cache)) - candidates.append(get_next_ball_linear_cushion_event(shot, collision_cache)) - candidates.append(get_next_ball_pocket_event(shot, collision_cache)) - candidates.append(get_next_ball_table_event(shot, collision_cache)) + + if self.is_3d: + candidates.append(get_next_ball_ball_3d_event(shot, collision_cache)) + candidates.append( + get_next_ball_circular_cushion_3d_event(shot, collision_cache) + ) + candidates.append( + get_next_ball_linear_cushion_3d_event(shot, collision_cache) + ) + candidates.append(get_next_ball_pocket_3d_event(shot, collision_cache)) + candidates.append(get_next_ball_table_event(shot, collision_cache)) + else: + candidates.append(get_next_ball_ball_2d_event(shot, collision_cache)) + candidates.append( + get_next_ball_circular_cushion_2d_event(shot, collision_cache) + ) + candidates.append( + get_next_ball_linear_cushion_2d_event(shot, collision_cache) + ) + candidates.append(get_next_ball_pocket_2d_event(shot, collision_cache)) min_time = min(event.time for event in candidates) diff --git a/tests/evolution/event_based/test_simulate.py b/tests/evolution/event_based/test_simulate.py index 7670ebf8..3baf6bcb 100644 --- a/tests/evolution/event_based/test_simulate.py +++ b/tests/evolution/event_based/test_simulate.py @@ -11,7 +11,7 @@ from pooltool.evolution.event_based.cache import CollisionCache from pooltool.evolution.event_based.detect import ( EventDetector, - get_next_ball_ball_event, + get_next_ball_ball_2d_event, ) from pooltool.evolution.event_based.simulate import simulate from pooltool.objects import Ball, BilliardTableSpecs, Cue, Table @@ -452,7 +452,7 @@ def test_ball_ball_collision_for_intersecting_balls(): _assert_rolling(system.balls["cue"].state.rvw, system.balls["cue"].params.R) assert _DETECTOR.get_next_event(system).event_type == EventType.BALL_BALL - collision_event = get_next_ball_ball_event(system, CollisionCache()) + collision_event = get_next_ball_ball_2d_event(system, CollisionCache()) assert collision_event.time != np.inf assert collision_event.time == 0 From 8b996605ac2d89118f7d2207ab46a48a2bdc2ee3 Mon Sep 17 00:00:00 2001 From: Evan Kiefl Date: Mon, 18 May 2026 00:52:16 -0700 Subject: [PATCH 4/4] Add airborne demos --- .../evolution/event_based/detect/ball_ball.py | 2 +- .../event_based/detect/ball_cushion.py | 9 +- .../event_based/detect/ball_pocket.py | 2 +- sandbox/airborne_demos.py | 133 ++++++++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 sandbox/airborne_demos.py diff --git a/pooltool/evolution/event_based/detect/ball_ball.py b/pooltool/evolution/event_based/detect/ball_ball.py index 2c3c550b..1796f7ea 100644 --- a/pooltool/evolution/event_based/detect/ball_ball.py +++ b/pooltool/evolution/event_based/detect/ball_ball.py @@ -78,4 +78,4 @@ def get_next_ball_ball_2d_event(shot: System, collision_cache: CollisionCache) - def get_next_ball_ball_3d_event(shot: System, collision_cache: CollisionCache) -> Event: - raise NotImplementedError("3D ball-ball detection has not been vendored yet") + return null_event(np.inf) diff --git a/pooltool/evolution/event_based/detect/ball_cushion.py b/pooltool/evolution/event_based/detect/ball_cushion.py index d67cbc65..6eaf494e 100644 --- a/pooltool/evolution/event_based/detect/ball_cushion.py +++ b/pooltool/evolution/event_based/detect/ball_cushion.py @@ -70,9 +70,8 @@ def get_next_ball_linear_cushion_2d_event( def get_next_ball_linear_cushion_3d_event( shot: System, collision_cache: CollisionCache ) -> Event: - raise NotImplementedError( - "3D ball-linear-cushion detection has not been vendored yet" - ) + """3D ball-linear-cushion detection — not vendored yet; emits no event.""" + return null_event(np.inf) def get_next_ball_circular_cushion_2d_event( @@ -123,6 +122,4 @@ def get_next_ball_circular_cushion_2d_event( def get_next_ball_circular_cushion_3d_event( shot: System, collision_cache: CollisionCache ) -> Event: - raise NotImplementedError( - "3D ball-circular-cushion detection has not been vendored yet" - ) + return null_event(np.inf) diff --git a/pooltool/evolution/event_based/detect/ball_pocket.py b/pooltool/evolution/event_based/detect/ball_pocket.py index 1424f873..9d6311de 100644 --- a/pooltool/evolution/event_based/detect/ball_pocket.py +++ b/pooltool/evolution/event_based/detect/ball_pocket.py @@ -57,4 +57,4 @@ def get_next_ball_pocket_2d_event( def get_next_ball_pocket_3d_event( shot: System, collision_cache: CollisionCache ) -> Event: - raise NotImplementedError("3D ball-pocket detection has not been vendored yet") + return null_event(np.inf) diff --git a/sandbox/airborne_demos.py b/sandbox/airborne_demos.py new file mode 100644 index 00000000..18a61967 --- /dev/null +++ b/sandbox/airborne_demos.py @@ -0,0 +1,133 @@ +#! /usr/bin/env python +"""Demos 3D trajectory (work in progress) + +Usage: + python sandbox/airborne_demos.py --name drop +""" + +import argparse + +import attrs + +from pooltool import constants as const +from pooltool.evolution.engine import SimulationEngine +from pooltool.evolution.event_based.simulate import simulate +from pooltool.interact import show +from pooltool.objects.ball.datatypes import Ball +from pooltool.objects.cue.datatypes import Cue +from pooltool.objects.table.datatypes import Table +from pooltool.physics.dimensionality import Dim +from pooltool.physics.resolve.resolver import Resolver +from pooltool.physics.resolve.stick_ball.instantaneous_point import ( + InstantaneousPoint3D, +) +from pooltool.system.datatypes import System + + +def _build_3d_engine() -> SimulationEngine: + """Build a SimulationEngine with ``is_3d=True``. + + Every resolver strategy that carries a ``dim`` tag is patched to + ``Dim.BOTH`` so the engine constructs; the stick-ball strategy is + swapped to ``InstantaneousPoint3D`` so cue elevation produces real + vertical velocity. + """ + # Patches all defaults to dim.BOTH so the engine constructs + resolver = Resolver.default() + for field in attrs.fields(type(resolver)): + strategy = getattr(resolver, field.name) + if hasattr(strategy, "dim"): + strategy.dim = Dim.BOTH + + # Replace all working 3D resolvers + resolver.stick_ball = InstantaneousPoint3D() + + return SimulationEngine(resolver=resolver, is_3d=True) + + +def _empty_cue() -> Cue: + """A cue with ``V0=0`` so the simulator doesn't fire a stick strike at t=0. + + The default ``Cue`` constructor sets ``V0=2.0``; the stick-ball detector + fires whenever ``V0 > 0`` and ``_system_has_energy`` reports false. For + a ball that's airborne but momentarily at apex (vz=0), kinetic energy is + zero and the detector would trigger a strike on the handcrafted state. + Explicitly zero V0 to suppress that. + + TODO: Fix underlying problem in codebase + """ + cue = Cue(cue_ball_id="cue") + cue.V0 = 0 + return cue + + +def drop() -> System: + """Ball dropped from 0.3 m with a small horizontal nudge in +x.""" + ball = Ball.create("cue", xy=(0.5, 0.5)) + ball.state.rvw[0, 2] = 0.3 + ball.state.rvw[1, 0] = 0.5 + ball.state.s = const.airborne + + return System( + cue=_empty_cue(), + table=Table.default(), + balls=(ball,), + ) + + +def impulse_into() -> System: + """Strong downward strike with a small horizontal nudge in +y.""" + ball = Ball.create("cue", xy=(0.5, 0.5)) + ball.state.rvw[1, 1] = 0.5 + ball.state.rvw[1, 2] = -5.0 + ball.state.s = const.airborne + + return System( + cue=_empty_cue(), + table=Table.default(), + balls=(ball,), + ) + + +def jump() -> System: + """A genuine jump shot — cue strike at 60° elevation produces vz via the 3D resolver. + + No handcrafted ``rvw`` here: the cue strikes a ball at rest on the table + surface, and ``InstantaneousPoint3D`` lifts it off via ``v·sin(theta)``. + """ + ball = Ball.create("cue", xy=(0.5, 0.5)) + cue = Cue(cue_ball_id="cue") + cue.set_state(V0=2.0, phi=90.0, theta=60.0, a=0.0, b=0.0) + + return System( + cue=cue, + table=Table.default(), + balls=(ball,), + ) + + +_map = { + "drop": drop, + "impulse_into": impulse_into, + "jump": jump, +} + + +def main(name: str) -> None: + engine = _build_3d_engine() + shot = _map[name]() + simulate(shot, engine=engine, inplace=True) + show(shot) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser("Airborne ball demos in 3D mode.") + ap.add_argument("--name", choices=list(_map.keys()) + ["all"], required=True) + args = ap.parse_args() + + if args.name == "all": + for name in _map: + print(f"Running {name}...") + main(name) + else: + main(args.name)