Skip to content

geomotif.bases

Base classes that turn a formula, a grammar or a rule into a motif.

Pick the one that matches how your design is defined, not what it looks like:

============================ ========================================== Base You implement ============================ ========================================== :class:ParametricMotif position(u) -> Point :class:PolarMotif radius(theta) -> float :class:MultiCurveMotif curves() -> Iterable[Curve] :class:PolygonMotif outlines() -> Iterable[Sequence[Point]] :class:LSystemMotif an axiom, rewrite rules and a turn angle :class:SegmentMotif nodes() and edges() :class:LatticeTiling cell() and basis() :class:SubstitutionTiling seed(), subdivide() and outline() ============================ ==========================================

The split between :class:ParametricMotif and :class:PolygonMotif is the one worth getting right: a curve is measured at evenly spaced parameters, a polygon is listed. Measuring a polygon rounds its corners off.

Anything that fits none of them subclasses :class:~geomotif.Motif directly and writes build() by hand, which is one method. The bases are a convenience, never a requirement.

Modules:

Name Description
lsystem

Turtle graphics driven by an L-system grammar.

parametric

Bases for motifs defined by a formula.

polygon

Base for motifs that are an exact list of corners.

segments

Base for motifs that are straight lines between a set of points.

tiling

Bases for tilings: one cell repeated on a lattice, or tiles that subdivide.

Classes:

Name Description
LSystemMotif

Base for a motif described by a grammar and drawn with a turtle.

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).

PolygonMotif

Base for a motif drawn as one or more exact corner sequences.

SegmentMotif

Base for a motif built from straight segments between indexed points.

LatticeTiling

Base for a periodic tiling: one cell, repeated on two basis vectors.

SubstitutionTiling

Base for an aperiodic tiling: seed tiles, subdivided :attr:depth times.

LSystemMotif dataclass

LSystemMotif(*, depth: int = 4, step: float = 1.0, start_angle: float = 0.0)

Bases: Motif, ABC

Base for a motif described by a grammar and drawn with a turtle.

Define :attr:axiom, :attr:rules and :attr:angle as class variables; everything else is a parameter with a sensible default. A concrete fractal is four lines::

@register("koch", family="fractal")
@dataclass(frozen=True, slots=True)
class KochCurve(LSystemMotif):
    axiom = "F"
    rules: ClassVar[Mapping[str, str]] = {"F": "F+F--F+F"}
    angle = math.pi / 3

The ClassVar annotation on :attr:rules is not decoration: without it, a mutable class attribute in a dataclass body is ambiguous to reader and linter alike, and annotating it as anything else would make it a constructor parameter with a mutable default, which dataclasses reject.

Notes

Both the string expansion and the point count grow exponentially with :attr:depth; the expansion is capped so that an accidental depth=20 raises instead of exhausting memory.

Methods:

Name Description
expand

Return the axiom rewritten :attr:depth times.

expand

expand() -> str

Return the axiom rewritten :attr:depth times.

Raises:

Type Description
ValueError

If depth is negative, if no axiom was defined, or if the expansion outgrows what can usefully be drawn.

Source code in src/geomotif/bases/lsystem.py
def expand(self) -> str:
    """Return the axiom rewritten :attr:`depth` times.

    Raises
    ------
    ValueError
        If ``depth`` is negative, if no axiom was defined, or if the
        expansion outgrows what can usefully be drawn.
    """
    if self.depth < 0:
        raise ValueError(f"depth must be >= 0, got {self.depth}")
    if not self.axiom:
        raise ValueError(
            f"{type(self).__name__} must define a non-empty `axiom` class variable"
        )

    current = self.axiom
    for round_number in range(self.depth):
        current = "".join(self.rules.get(symbol, symbol) for symbol in current)
        if len(current) > _MAX_SYMBOLS:
            raise ValueError(
                f"{type(self).__name__} expanded to more than {_MAX_SYMBOLS} symbols "
                f"after {round_number + 1} of {self.depth} rounds; use a smaller depth"
            )
    return current

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)

PolygonMotif dataclass

PolygonMotif()

Bases: Motif, ABC

Base for a motif drawn as one or more exact corner sequences.

Implement :meth:outlines; :meth:build turns each one into a :class:~geomotif.Path::

@register("rectangle", family="primitive")
@dataclass(frozen=True, slots=True)
class Rectangle(PolygonMotif):
    width: float = 1.0
    height: float = 1.0

    def outlines(self) -> Iterable[Sequence[Point]]:
        w, h = self.width / 2.0, self.height / 2.0
        yield ((-w, -h), (w, -h), (w, h), (-w, h))

:meth:outlines is plural because one shape is not always one loop: the star polygon {6/2} is two overlaid triangles, and drawing it as a single path would invent an edge between them that is not there.

Notes

Corners are emitted as given -- no deduplication, no collinear-point removal. A motif that wants a vertex repeated (to hold a pen, to mark a lattice site) is entitled to it, and guessing otherwise would silently change geometry the author chose.

Methods:

Name Description
outlines

Return the corner sequences to draw, one per stroke. At least one.

outlines abstractmethod

outlines() -> Iterable[Sequence[Point]]

Return the corner sequences to draw, one per stroke. At least one.

Source code in src/geomotif/bases/polygon.py
@abstractmethod
def outlines(self) -> Iterable[Sequence[Point]]:
    """Return the corner sequences to draw, one per stroke. At least one."""

SegmentMotif dataclass

SegmentMotif(*, merge: bool = False, show_nodes: bool = False)

Bases: Motif, ABC

Base for a motif built from straight segments between indexed points.

Implement :meth:nodes and :meth:edges; :meth:build turns them into strokes. A ten-line class gets you the whole times-table family::

@register("modular.multiplication", family="graph", example={"modulus": 200})
@dataclass(frozen=True, slots=True)
class ModularMultiplication(SegmentMotif):
    modulus: int = 200
    factor: int = 2

    def nodes(self) -> Sequence[Point]:
        step = math.tau / self.modulus
        return [(math.cos(i * step), math.sin(i * step)) for i in range(self.modulus)]

    def edges(self) -> Iterable[tuple[int, int]]:
        return ((i, self.factor * i % self.modulus) for i in range(self.modulus))
Notes

Edges are undirected: (i, j) and (j, i) are the same segment and the duplicate is dropped, as is any self-loop (i, i). Both are routine outputs of an arithmetic edge rule rather than mistakes, so neither is an error -- but drawing them would waste plotter time on nothing.

Methods:

Name Description
nodes

Return the points the edges are drawn between.

edges

Return index pairs into :meth:nodes, one per segment.

nodes abstractmethod

nodes() -> Sequence[Point]

Return the points the edges are drawn between.

Source code in src/geomotif/bases/segments.py
@abstractmethod
def nodes(self) -> Sequence[Point]:
    """Return the points the edges are drawn between."""

edges abstractmethod

edges() -> Iterable[tuple[int, int]]

Return index pairs into :meth:nodes, one per segment.

Source code in src/geomotif/bases/segments.py
@abstractmethod
def edges(self) -> Iterable[tuple[int, int]]:
    """Return index pairs into :meth:`nodes`, one per segment."""

LatticeTiling dataclass

LatticeTiling(*, region: Bounds, clip: bool = True)

Bases: Motif, ABC

Base for a periodic tiling: one cell, repeated on two basis vectors.

Implement :meth:cell (the geometry of one tile) and :meth:basis (the two translations that repeat it), and give the motif a :attr:region to fill::

@register("tiling.square", family="tiling")
@dataclass(frozen=True, slots=True)
class SquareTiling(LatticeTiling):
    size: float = 10.0

    def basis(self) -> tuple[Point, Point]:
        return ((self.size, 0.0), (0.0, self.size))

    def cell(self) -> Design:
        s = self.size
        return Design((Path(((0, 0), (s, 0), (s, s), (0, s)), closed=True),))

:meth:basis is a method rather than a field because for most tilings the vectors follow from the motif's own parameters, as above; a tiling that genuinely wants caller-supplied vectors can declare a field and return it.

Methods:

Name Description
basis

Return the two translation vectors that generate the lattice.

cell

Return the geometry of a single cell, at lattice origin.

basis abstractmethod

basis() -> tuple[Point, Point]

Return the two translation vectors that generate the lattice.

Source code in src/geomotif/bases/tiling.py
@abstractmethod
def basis(self) -> tuple[Point, Point]:
    """Return the two translation vectors that generate the lattice."""

cell abstractmethod

cell() -> Design

Return the geometry of a single cell, at lattice origin.

Source code in src/geomotif/bases/tiling.py
@abstractmethod
def cell(self) -> Design:
    """Return the geometry of a single cell, at lattice origin."""

SubstitutionTiling dataclass

SubstitutionTiling(*, depth: int = 4)

Bases: Motif, ABC

Base for an aperiodic tiling: seed tiles, subdivided :attr:depth times.

The tile type is yours -- a dataclass of three vertices, a rhomb with an orientation, whatever the substitution rule needs. The base only ever passes tiles back to your own methods, so it never has to know.

Implement :meth:seed (the starting tiles), :meth:subdivide (one tile to its replacements) and :meth:outline (a tile to the strokes that draw it).

Notes

Tile count grows geometrically -- a rule with three replacements reaches a hundred thousand tiles by depth eleven -- so the expansion is capped and raises rather than exhausting memory.

Shared edges are drawn once per tile that owns them, so a plotter will trace most edges twice. Deduplicating them means comparing floating-point vertices for equality, which is a judgement call about tolerance the base should not be making for you.

Methods:

Name Description
seed

Return the tiles the subdivision starts from.

subdivide

Return the tiles that replace tile in the next round.

outline

Return the strokes that draw tile.

tiles

Return the seed tiles subdivided :attr:depth times.

seed abstractmethod

seed() -> Iterable[TileT]

Return the tiles the subdivision starts from.

Source code in src/geomotif/bases/tiling.py
@abstractmethod
def seed(self) -> Iterable[TileT]:
    """Return the tiles the subdivision starts from."""

subdivide abstractmethod

subdivide(tile: TileT) -> Iterable[TileT]

Return the tiles that replace tile in the next round.

Source code in src/geomotif/bases/tiling.py
@abstractmethod
def subdivide(self, tile: TileT) -> Iterable[TileT]:
    """Return the tiles that replace ``tile`` in the next round."""

outline abstractmethod

outline(tile: TileT) -> Iterable[Path]

Return the strokes that draw tile.

Source code in src/geomotif/bases/tiling.py
@abstractmethod
def outline(self, tile: TileT) -> Iterable[Path]:
    """Return the strokes that draw ``tile``."""

tiles

tiles() -> tuple[TileT, ...]

Return the seed tiles subdivided :attr:depth times.

Exposed separately from :meth:build because the tiles themselves are often what you want -- to count them, to check a substitution rule preserves area, or to color them by type.

Source code in src/geomotif/bases/tiling.py
def tiles(self) -> tuple[TileT, ...]:
    """Return the seed tiles subdivided :attr:`depth` times.

    Exposed separately from :meth:`build` because the tiles themselves are
    often what you want -- to count them, to check a substitution rule
    preserves area, or to color them by type.
    """
    if self.depth < 0:
        raise ValueError(f"depth must be >= 0, got {self.depth}")

    current = tuple(self.seed())
    if not current:
        raise ValueError(f"{type(self).__name__}.seed() returned no tiles")

    for round_number in range(self.depth):
        current = tuple(child for tile in current for child in self.subdivide(tile))
        if not current:
            raise ValueError(
                f"{type(self).__name__}.subdivide() emptied the tiling in round "
                f"{round_number + 1}: every tile must be replaced by at least one tile"
            )
        if len(current) > _MAX_TILES:
            raise ValueError(
                f"{type(self).__name__} expanded to {len(current)} tiles after "
                f"{round_number + 1} of {self.depth} rounds (limit {_MAX_TILES}); "
                f"use a smaller depth"
            )
    return current