#! /usr/bin/env python
from __future__ import annotations
from typing import Optional
from attrs import define, evolve, field, fields_dict
[docs]@define(frozen=True)
class CueSpecs:
"""Cue stick specifications
All units are SI.
Attributes:
brand:
The brand.
M:
The mass.
length:
The cue length.
tip_radius:
The cue tip radius.
butt_radius:
The butt radius.
"""
brand: str = field(default="Predator")
M: float = field(default=0.567)
length: float = field(default=1.4732)
tip_radius: float = field(default=0.007)
butt_radius: float = field(default=0.02)
[docs] @staticmethod
def default() -> CueSpecs:
"""Construct a default cue spec"""
return CueSpecs()
@staticmethod
def snooker() -> CueSpecs:
raise NotImplementedError()
[docs]@define
class Cue:
"""A cue stick
Attributes:
id:
An ID for the cue.
V0:
The impact speed.
Units are *m/s*.
Warning: This is the speed of the cue stick upon impact, not the speed of the
ball upon impact.
phi:
The directional strike angle.
The horizontal direction of the cue's orientation relative to the table layout.
**Specified in degrees**.
If you imagine facing from the head rail (where the cue is positioned for a
break shot) towards the foot rail (where the balls are racked),
- :math:`\\phi = 0` corresponds to striking the cue ball to the right
- :math:`\\phi = 90` corresponds to striking the cue ball towards the foot rail
- :math:`\\phi = 180` corresponds to striking the cue ball to the left
- :math:`\\phi = 270` corresponds to striking the cue ball towards the head rail
- :math:`\\phi = 360` corresponds to striking the cue ball to the right
theta:
The cue inclination angle.
The vertical angle of the cue stick relative to the table surface. **Specified
in degrees**.
- :math:`\\theta = 0` corresponds to striking the cue ball parallel with the
table (no massé)
- :math:`\\theta = 90` corresponds to striking the cue ball downwards into the
table (max massé)
a:
The amount and direction of side spin.
- :math:`a = -1` is the rightmost side of ball
- :math:`a = +1` is the leftmost side of the ball
b:
The amount of top/bottom spin.
- :math:`b = -1` is the bottom-most side of the ball
- :math:`b = +1` is the top-most side of the ball
cue_ball_id:
The ball ID of the ball being cued.
specs:
The cue specs.
"""
id: str = field(default="cue_stick")
V0: float = field(default=2.0)
phi: float = field(default=0.0)
theta: float = field(default=0.0)
a: float = field(default=0.0)
b: float = field(default=0.25)
cue_ball_id: str = field(default="cue")
specs: CueSpecs = field(factory=CueSpecs.default)
def __repr__(self):
lines = [
f"<{self.__class__.__name__} object at {hex(id(self))}>",
f" ├── V0 : {self.V0}",
f" ├── phi : {self.phi}",
f" ├── a : {self.a}",
f" ├── b : {self.b}",
f" └── theta : {self.theta}",
]
return "\n".join(lines) + "\n"
[docs] def copy(self) -> Cue:
"""Create a copy
Note:
:attr:`specs` is shared between ``self`` and the copy, but that's ok because
it's frozen and has no mutable attributes.
"""
return evolve(self)
[docs] def reset_state(self) -> None:
"""Resets :attr:`V0`, :attr:`phi`, :attr:`theta`, :attr:`a` and :attr:`b` to their defaults."""
field_defaults = {
fname: field.default
for fname, field in fields_dict(self.__class__).items()
if fname in ("V0", "phi", "theta", "a", "b")
}
self.set_state(**field_defaults)
[docs] def set_state(
self,
V0: Optional[float] = None,
phi: Optional[float] = None,
theta: Optional[float] = None,
a: Optional[float] = None,
b: Optional[float] = None,
cue_ball_id: Optional[str] = None,
) -> None:
"""Set the cueing parameters
Args:
V0: See :attr:`V0`
phi: See :attr:`phi`
theta: See :attr:`theta`
a: See :attr:`a`
b: See :attr:`b`
cue_ball_id: See :attr:`cue_ball_id`
If any arguments are ``None``, they will be left untouched--they will not be set
to None.
"""
if V0 is not None:
self.V0 = V0
if phi is not None:
self.phi = phi
if theta is not None:
self.theta = theta
if a is not None:
self.a = a
if b is not None:
self.b = b
if cue_ball_id is not None:
self.cue_ball_id = cue_ball_id
[docs] @classmethod
def default(cls) -> Cue:
"""Construct a cue with defaults"""
return Cue()