Skip to content
Merged
25 changes: 4 additions & 21 deletions docs/examples/30_degree_rule.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@
},
{
"cell_type": "code",
"execution_count": 6,
"execution_count": null,
"id": "d29a2bda",
"metadata": {
"execution": {
Expand All @@ -221,25 +221,8 @@
"shell.execute_reply": "2026-03-15T06:51:53.253495Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"System simulated: True\n"
]
}
],
"source": [
"# Create a default physics engine, then overwrite ball-ball model with frictionless, elastic model.\n",
"engine = pt.physics.PhysicsEngine()\n",
"engine.resolver.ball_ball = pt.physics.ball_ball_models[pt.physics.BallBallModel.FRICTIONLESS_ELASTIC]()\n",
"\n",
"pt.simulate(system, engine=engine, inplace=True)\n",
"pt.continuize(system, dt=0.01, inplace=True)\n",
"\n",
"print(f\"System simulated: {system.simulated}\")"
]
"outputs": [],
"source": "# Create a default simulation engine, then overwrite ball-ball model with frictionless, elastic model.\nengine = pt.evolution.SimulationEngine()\nengine.resolver.ball_ball = pt.physics.ball_ball_models[pt.physics.BallBallModel.FRICTIONLESS_ELASTIC]()\n\npt.simulate(system, engine=engine, inplace=True)\npt.continuize(system, dt=0.01, inplace=True)\n\nprint(f\"System simulated: {system.simulated}\")"
},
{
"cell_type": "markdown",
Expand Down Expand Up @@ -1371,4 +1354,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}
5 changes: 4 additions & 1 deletion docs/include_exclude.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"pooltool.ruleset.snooker",
# API: pooltool.physics
"pooltool.physics.evolve",
"pooltool.physics.motion",
"pooltool.physics.resolve",
],
"module": [
Expand Down Expand Up @@ -56,8 +57,10 @@
"pooltool.system.datatypes",
# API: pooltool.game
"pooltool.game.datatypes",
# API: pooltool.evolution
"pooltool.evolution.engine",
# API: pooltool.physics
"pooltool.physics.engine",
"pooltool.physics.utils",
# API: pooltool.physics.resolve
"pooltool.physics.resolve.models",
"pooltool.physics.resolve.resolver",
Expand Down
2 changes: 1 addition & 1 deletion docs/meta/developer_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ To add an inlaid dropdown signature:
Add optional text here

```{eval-rst}
.. autoclass:: pooltool.physics.PhysicsEngine
.. autoclass:: pooltool.evolution.SimulationEngine
:noindex:
```
:::
Expand Down
18 changes: 16 additions & 2 deletions docs/resources/custom_physics.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,28 @@ class CoreBallLCushionCollision(ABC):

With these, we can draft our template by following the example code: [5ecddb2c0c010e3f058e666fd5a7fc1f10117638](https://github.com/ekiefl/pooltool/commit/5ecddb2c0c010e3f058e666fd5a7fc1f10117638)

It's just missing two things. First, the class must be an attrs class. Pooltool requires that all the resolver models are [attrs](https://www.attrs.org/en/stable/) classes. If you've never used attrs before, stick close to the example and you'll have no problems. Second, the class must have an attribute called `model`, and this attribute should be the Enum member that you had previously added to `pooltool/physics/resolve/models.py`.
It's just missing three things. First, the class must be an attrs class. Pooltool requires that all the resolver models are [attrs](https://www.attrs.org/en/stable/) classes. If you've never used attrs before, stick close to the example and you'll have no problems. Second, the class must have an attribute called `model`, and this attribute should be the Enum member that you had previously added to `pooltool/physics/resolve/models.py`. Third, the class must have an attribute called `dim` that declares the simulation dimensionality your model supports.

To apply these changes, follow the example code: [9c12a6efa2b9d201d8cedfc75b1a83b8134dd7ec](https://github.com/ekiefl/pooltool/commit/9c12a6efa2b9d201d8cedfc75b1a83b8134dd7ec). Since I added `UNREALISTIC` to the `BallLCushionModel` model, I added the following attribute to my class:
To apply the `model` requirement, follow the example code: [9c12a6efa2b9d201d8cedfc75b1a83b8134dd7ec](https://github.com/ekiefl/pooltool/commit/9c12a6efa2b9d201d8cedfc75b1a83b8134dd7ec). Since I added `UNREALISTIC` to the `BallLCushionModel` model, I added the following attribute to my class:

```python
model: BallLCushionModel = attrs.field(default=BallLCushionModel.UNREALISTIC, init=False, repr=False)
```

For the `dim` requirement, add a `dim` field declaring whether your model is safe in 2D, 3D, or both:

```python
from pooltool.physics.dimensionality import Dim

dim: Dim = attrs.field(default=Dim.TWO, init=False, repr=False)
```

`Dim` is a capability declaration consumed at engine construction:

- `Dim.TWO` — your model is safe only when [](#pooltool.evolution.engine.SimulationEngine)'s `is_3d` is `False`. Use this if your model assumes balls are on the table surface (z=R, vz=0).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix broken SimulationEngine cross-reference target.

This anchor points to pooltool.evolution.engine.SimulationEngine, but that module is now excluded from AutoAPI. Link to the exported API path (pooltool.evolution.SimulationEngine) instead.

🔧 Suggested patch
-- `Dim.TWO` — your model is safe only when [](`#pooltool.evolution.engine.SimulationEngine`)'s `is_3d` is `False`. Use this if your model assumes balls are on the table surface (z=R, vz=0).
+- `Dim.TWO` — your model is safe only when [](`#pooltool.evolution.SimulationEngine`)'s `is_3d` is `False`. Use this if your model assumes balls are on the table surface (z=R, vz=0).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `Dim.TWO` — your model is safe only when [](#pooltool.evolution.engine.SimulationEngine)'s `is_3d` is `False`. Use this if your model assumes balls are on the table surface (z=R, vz=0).
- `Dim.TWO` — your model is safe only when [](`#pooltool.evolution.SimulationEngine`)'s `is_3d` is `False`. Use this if your model assumes balls are on the table surface (z=R, vz=0).
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 202-202: Link fragments should be valid

(MD051, link-fragments)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/resources/custom_physics.md` at line 202, Update the broken markdown
anchor for the SimulationEngine reference used in the Dim.TWO line: replace the
old anchor target "`#pooltool.evolution.engine.SimulationEngine`" with the
exported API path anchor "`#pooltool.evolution.SimulationEngine`" (so the link
from the Dim.TWO description points to pooltool.evolution.SimulationEngine).
Locate the text mentioning Dim.TWO and adjust the link target accordingly to the
exported SimulationEngine symbol.

- `Dim.THREE` — your model is safe only when `is_3d` is `True`. Use this if your model assumes 3D ball state (e.g. it produces or handles airborne balls).
- `Dim.BOTH` — your model behaves identically in either mode. This is a strong promise: a `Dim.BOTH` model doesn't care or know whether it's handling a 2D/3D simulation.

Great, now we are done with the boilerplate code. But `resolve` currently does *nothing*, it just returns what is handed to it. Let's change that.

#### Implement the logic
Expand Down
2 changes: 1 addition & 1 deletion pooltool/ani/hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from pooltool.ani.globals import Global
from pooltool.objects.ball.datatypes import Ball, BallParams
from pooltool.objects.cue.datatypes import Cue, CueSpecs
from pooltool.ptmath.utils import tip_center_offset
from pooltool.physics.utils import tip_center_offset
from pooltool.ruleset.datatypes import BallInHandOptions
from pooltool.utils import panda_path
from pooltool.utils.strenum import StrEnum, auto
Expand Down
3 changes: 2 additions & 1 deletion pooltool/ani/modes/aim.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
from pooltool.ani.mouse import MouseMode, mouse
from pooltool.ani.scene import visual
from pooltool.config import settings
from pooltool.ptmath.utils import norm2d, tip_contact_offset
from pooltool.physics.utils import tip_contact_offset
from pooltool.ptmath.utils import norm2d
from pooltool.system.datatypes import multisystem


Expand Down
3 changes: 2 additions & 1 deletion pooltool/ani/modes/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
from pooltool.ani.mouse import MouseMode, mouse
from pooltool.ani.scene import visual
from pooltool.config import settings
from pooltool.ptmath.utils import norm2d, tip_contact_offset
from pooltool.physics.utils import tip_contact_offset
from pooltool.ptmath.utils import norm2d
from pooltool.system.datatypes import multisystem


Expand Down
2 changes: 2 additions & 0 deletions pooltool/evolution/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Shot evolution algorithm routines and utilities"""

from pooltool.evolution.continuous import continuize, interpolate_ball_states
from pooltool.evolution.engine import SimulationEngine
from pooltool.evolution.event_based.simulate import simulate

__all__ = [
"SimulationEngine",
"continuize",
"simulate",
"interpolate_ball_states",
Expand Down
62 changes: 62 additions & 0 deletions pooltool/evolution/engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""The simulation engine of pooltool"""

from __future__ import annotations

import attrs

from pooltool.evolution.event_based.detect import EventDetector
from pooltool.physics.dimensionality import Dim
from pooltool.physics.resolve import Resolver


@attrs.define
class SimulationEngine:
"""A pluggable bundle of strategies used by the simulator.

Holds the strategies that define how a simulation is carried out: how events are
detected and how they are resolved. The simulator is handed an instance of this
class and routes work to its components.

Attributes:
resolver:
The strategy responsible for resolving events.
detector:
The strategy responsible for detecting the next event.
is_3d:
Whether the simulation supports the airborne motion state and ball-table
events. Validated at construction against the dimensionality capability
(``dim``) of every bundled strategy in ``resolver`` and ``detector``.
"""

resolver: Resolver = attrs.field(factory=Resolver.default)
detector: EventDetector = attrs.field(factory=EventDetector.default)
is_3d: bool = False

def __attrs_post_init__(self) -> None:
self._validate_dimensionality()

def _validate_dimensionality(self) -> None:
required = Dim.THREE if self.is_3d else Dim.TWO
for bundle in (self.resolver, self.detector):
for field in attrs.fields(type(bundle)):
strategy = getattr(bundle, field.name)
if not attrs.has(type(strategy)):
continue
if not hasattr(strategy, "dim"):
raise AttributeError(
f"{type(bundle).__name__}.{field.name} "
f"({type(strategy).__name__}) is missing required "
f"'dim' attribute"
)
if strategy.dim not in (required, Dim.BOTH):
Comment on lines +43 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Dimensionality validation is bypassable for non-attrs strategy implementations.

Line 43 skips validation when a strategy instance is not an attrs class. That allows incompatible custom strategies to pass without dim checks, despite the engine contract.

Suggested fix
-                if not attrs.has(type(strategy)):
-                    continue
                 if not hasattr(strategy, "dim"):
                     raise AttributeError(
                         f"{type(bundle).__name__}.{field.name} "
                         f"({type(strategy).__name__}) is missing required "
                         f"'dim' attribute"
                     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not attrs.has(type(strategy)):
continue
if not hasattr(strategy, "dim"):
raise AttributeError(
f"{type(bundle).__name__}.{field.name} "
f"({type(strategy).__name__}) is missing required "
f"'dim' attribute"
)
if strategy.dim not in (required, Dim.BOTH):
if not hasattr(strategy, "dim"):
raise AttributeError(
f"{type(bundle).__name__}.{field.name} "
f"({type(strategy).__name__}) is missing required "
f"'dim' attribute"
)
if strategy.dim not in (required, Dim.BOTH):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pooltool/evolution/engine.py` around lines 43 - 51, The current code bypasses
dimensionality checks for non-attrs strategies by using
attrs.has(type(strategy)); remove that attrs.has gate so validation always runs:
in the block referencing strategy, bundle, field and Dim, first ensure the
strategy exposes a dim attribute (keep the hasattr(strategy, "dim") check and
its AttributeError using bundle and field), then validate that strategy.dim is
in (required, Dim.BOTH) and raise the same error if not; do not rely on
attrs.has(type(strategy)) so custom non-attrs strategies can't skip the dim
checks.

raise ValueError(
f"{type(bundle).__name__}.{field.name} "
f"({type(strategy).__name__}) has dim={strategy.dim}, "
f"incompatible with is_3d={self.is_3d}; "
f"expected {required} or {Dim.BOTH}"
)


__all__ = [
"SimulationEngine",
]
21 changes: 21 additions & 0 deletions pooltool/evolution/event_based/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import annotations

from pooltool.physics.utils import get_ball_energy
from pooltool.system.datatypes import System


def _system_has_energy(system: System) -> bool:
"""Return True if any ball in the system has kinetic energy.

Cue energy (e.g. ``system.cue.V0 > 0``) does not count.
"""
return any(
bool(
get_ball_energy(
ball.state.rvw,
ball.params.R,
ball.params.m,
)
)
for ball in system.balls.values()
)
12 changes: 5 additions & 7 deletions pooltool/evolution/event_based/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import numpy as np

import pooltool.constants as const
import pooltool.ptmath as ptmath
from pooltool.events import (
AgentType,
Event,
Expand All @@ -19,6 +18,7 @@
)
from pooltool.events.utils import event_type_to_ball_indices
from pooltool.objects.ball.datatypes import Ball
from pooltool.physics.utils import get_roll_time, get_slide_time, get_spin_time
from pooltool.serialize import SerializeFormat, conversion
from pooltool.system.datatypes import System

Expand Down Expand Up @@ -75,26 +75,24 @@ def _next_transition(ball: Ball) -> Event:
return null_event(time=np.inf)

elif ball.state.s == const.spinning:
dtau_E = ptmath.get_spin_time(
dtau_E = get_spin_time(
ball.state.rvw, ball.params.R, ball.params.u_sp, ball.params.g
)
return spinning_stationary_transition(ball, ball.state.t + dtau_E)

elif ball.state.s == const.rolling:
dtau_E_spin = ptmath.get_spin_time(
dtau_E_spin = get_spin_time(
ball.state.rvw, ball.params.R, ball.params.u_sp, ball.params.g
)
dtau_E_roll = ptmath.get_roll_time(
ball.state.rvw, ball.params.u_r, ball.params.g
)
dtau_E_roll = get_roll_time(ball.state.rvw, ball.params.u_r, ball.params.g)

if dtau_E_spin > dtau_E_roll:
return rolling_spinning_transition(ball, ball.state.t + dtau_E_roll)
else:
return rolling_stationary_transition(ball, ball.state.t + dtau_E_roll)

elif ball.state.s == const.sliding:
dtau_E = ptmath.get_slide_time(
dtau_E = get_slide_time(
ball.state.rvw, ball.params.R, ball.params.u_s, ball.params.g
)
return sliding_rolling_transition(ball, ball.state.t + dtau_E)
Expand Down
33 changes: 33 additions & 0 deletions pooltool/evolution/event_based/detect/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from pooltool.evolution.event_based.detect.ball_ball import (
BallBallDetection,
BallBallDetectionStrategy,
)
from pooltool.evolution.event_based.detect.ball_cushion import (
BallCCushionDetection,
BallCCushionDetectionStrategy,
BallLCushionDetection,
BallLCushionDetectionStrategy,
)
from pooltool.evolution.event_based.detect.ball_pocket import (
BallPocketDetection,
BallPocketDetectionStrategy,
)
from pooltool.evolution.event_based.detect.detector import EventDetector
from pooltool.evolution.event_based.detect.stick_ball import (
StickBallDetection,
StickBallDetectionStrategy,
)

__all__ = [
"EventDetector",
"BallBallDetection",
"BallBallDetectionStrategy",
"BallCCushionDetection",
"BallCCushionDetectionStrategy",
"BallLCushionDetection",
"BallLCushionDetectionStrategy",
"BallPocketDetection",
"BallPocketDetectionStrategy",
"StickBallDetection",
"StickBallDetectionStrategy",
]
Loading
Loading