Skip to content

geomotif.bases.parametric

Bases for motifs defined by a formula.

Three layers, each a special case of the one above it:

  • :class:MultiCurveMotif -- the motif is several parametric strands at once (both branches of a Fermat spiral, both lobes of a Cassini oval).
  • :class:ParametricMotif -- the single-strand case: one position(u).
  • :class:PolarMotif -- the polar case: one radius(theta), with the cartesian conversion, the center offset and the theta range handled for you.

Implementing any of them buys arc-length resampling, every spacing curve, the transform layer, export and CLI exposure -- the whole library -- for what is usually a single line of maths.

Classes:

Name Description
Curve

One parametric strand of a motif, and how densely to measure it.

MultiCurveMotif

Base for a motif made of several parametric strands.

ParametricMotif

Base for a motif defined by a single position(u).

PolarMotif

Base for a motif defined by a single radius(theta).

Curve dataclass

Curve(position: Callable[[float], Point], domain: tuple[float, float] = (0.0, 1.0), closed: bool = False, turns: float = 1.0)

One parametric strand of a motif, and how densely to measure it.

Parameters:

Name Type Description Default
position callable

Maps a parameter to a point. Called across domain only.

required
domain (float, float)

Parameter range, inclusive at both ends. May run backwards.

(0.0, 1.0)
closed bool

Whether the strand returns to where it started. A closed strand's final sample is dropped, since :class:~geomotif.Path implies the seam rather than storing it.

False
turns float

How far the strand winds, in whole turns. Only ever used to choose a sample count: a curve that bends more needs measuring more finely.

1.0

MultiCurveMotif dataclass

MultiCurveMotif(*, resolution: int | None = None)

Bases: Motif, ABC

Base for a motif made of several parametric strands.

Implement :meth:curves; :meth:build measures each strand and returns one :class:~geomotif.Path per strand.

Notes

Subclasses implement hooks rather than overriding :meth:build, and the bases validate inside :meth:build rather than in __post_init__, both for the same reason: on Python 3.12 the zero-argument super() does not work inside a slots=True dataclass, so a subclass cannot reliably chain up to us. Nothing here ever requires it to.

Every field on a base is keyword-only, so a subclass is free to declare its own parameters positionally without tripping over the "no non-default argument after a default one" rule.

Methods:

Name Description
curves

Return the strands this motif is made of. At least one.

curves abstractmethod

curves() -> Iterable[Curve]

Return the strands this motif is made of. At least one.

Source code in src/geomotif/bases/parametric.py
@abstractmethod
def curves(self) -> Iterable[Curve]:
    """Return the strands this motif is made of. At least one."""

ParametricMotif dataclass

ParametricMotif(*, resolution: int | None = None)

Bases: MultiCurveMotif, ABC

Base for a motif defined by a single position(u).

Set :attr:domain and :attr:closed as class variables to describe the curve's shape; override :meth:sweep_turns if it winds more than once, so the sample density keeps up with it.

Examples:

::

@register("astroid", family="curve")
@dataclass(frozen=True, slots=True)
class Astroid(ParametricMotif):
    domain = (0.0, math.tau)
    closed = True

    size: float = 1.0

    def position(self, u: float) -> Point:
        return (
            self.size * math.cos(u) ** 3,
            self.size * math.sin(u) ** 3,
        )

Methods:

Name Description
position

Return the point at parameter u, which ranges over :attr:domain.

sweep_turns

Return how far the curve winds, in whole turns.

position abstractmethod

position(u: float) -> Point

Return the point at parameter u, which ranges over :attr:domain.

Source code in src/geomotif/bases/parametric.py
@abstractmethod
def position(self, u: float) -> Point:
    """Return the point at parameter ``u``, which ranges over :attr:`domain`."""

sweep_turns

sweep_turns() -> float

Return how far the curve winds, in whole turns.

Used only to pick a sample count. The default of one turn suits any curve that does not loop repeatedly; a spiral should return its actual revolution count so that a tightly wound one is still measured accurately.

Source code in src/geomotif/bases/parametric.py
def sweep_turns(self) -> float:
    """Return how far the curve winds, in whole turns.

    Used only to pick a sample count. The default of one turn suits any
    curve that does not loop repeatedly; a spiral should return its actual
    revolution count so that a tightly wound one is still measured
    accurately.
    """
    return 1.0

PolarMotif dataclass

PolarMotif(*, resolution: int | None = None, center: Point = (0.0, 0.0), theta_start: float = 0.0, theta_span: float = tau)

Bases: ParametricMotif, ABC

Base for a motif defined by a single radius(theta).

The whole extensibility story in eight lines::

@register("my-flower", family="polar")
@dataclass(frozen=True, slots=True)
class MyFlower(PolarMotif):
    k: float = 7.0

    def radius(self, theta: float) -> float:
        return math.sin(self.k * theta) + 0.4 * math.cos(17 * theta)
Notes

A negative radius reflects: the point is placed on the opposite ray, at theta + pi. That is what the cartesian conversion does naturally, it is the convention every plot of r = cos(k*theta) assumes, and it is what makes the petal count of a rose come out right. Clip the radius yourself in :meth:radius if you want the other convention.

The hook is named radius, so a subclass cannot also have a field called radius -- the two would collide in the class body. In practice that never bites: a shape whose radius is a constant parameter rather than a function of theta is a circle or an arc, and those are parametric rather than polar for exactly this reason.

Methods:

Name Description
radius

Return the radius at angle theta, in radians.

with_turns

Return a copy sweeping turns revolutions in the given direction.

radius abstractmethod

radius(theta: float) -> float

Return the radius at angle theta, in radians.

Source code in src/geomotif/bases/parametric.py
@abstractmethod
def radius(self, theta: float) -> float:
    """Return the radius at angle ``theta``, in radians."""

with_turns

with_turns(turns: float, *, clockwise: bool = False) -> Self

Return a copy sweeping turns revolutions in the given direction.

The same thing as setting :attr:theta_span to turns * tau, said the way a wound curve is usually described::

LogarithmicSpiral(b=0.15).with_turns(5, clockwise=True)

Parameters:

Name Type Description Default
turns float

Revolutions to sweep. Fractional turns are fine.

required
clockwise bool

Sweep direction. Counter-clockwise by default, matching the standard math convention the rest of the library uses.

False
Source code in src/geomotif/bases/parametric.py
def with_turns(self, turns: float, *, clockwise: bool = False) -> Self:
    """Return a copy sweeping ``turns`` revolutions in the given direction.

    The same thing as setting :attr:`theta_span` to ``turns * tau``, said
    the way a wound curve is usually described::

        LogarithmicSpiral(b=0.15).with_turns(5, clockwise=True)

    Parameters
    ----------
    turns : float
        Revolutions to sweep. Fractional turns are fine.
    clockwise : bool, optional
        Sweep direction. Counter-clockwise by default, matching the
        standard math convention the rest of the library uses.
    """
    span = math.tau * turns
    return replace(self, theta_span=-span if clockwise else span)