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 |
PolarMotif |
Base for a motif defined by a single |
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: |
LSystemMotif
dataclass
¶
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: |
expand
¶
Return the axiom rewritten :attr:depth times.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/geomotif/bases/lsystem.py
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 |
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: |
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
¶
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. |
ParametricMotif
dataclass
¶
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 |
sweep_turns |
Return how far the curve winds, in whole turns. |
position
abstractmethod
¶
sweep_turns
¶
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
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 |
with_turns |
Return a copy sweeping |
radius
abstractmethod
¶
with_turns
¶
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
PolygonMotif
dataclass
¶
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
¶
SegmentMotif
dataclass
¶
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
abstractmethod
¶
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
¶
SubstitutionTiling
dataclass
¶
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 |
outline |
Return the strokes that draw |
tiles |
Return the seed tiles subdivided :attr: |
seed
abstractmethod
¶
subdivide
abstractmethod
¶
tiles
¶
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.