Skip to content

geomotif

geomotif -- generate and plot geometric designs.

A motif is a parameterized recipe for geometry; applying a transform to what it produced gives a design, which is what you plot or export. That is the whole mental model::

from geomotif import PowerSpacing
from geomotif.motifs import SpiralBetween

design = SpiralBetween(
    start=(200, 0),  # required — first point (always included)
    end=(20, 0),  # required — last point (always included)
    center=(0, 0),  # point the spiral winds around (default shown)
    turns=3,  # extra full revolutions (default 0)
).generate(
    120, spacing=PowerSpacing(2.5)
)

for x, y in design:
    ...

Point placement is arc-length exact: equal spacing means the same real x,y distance between every consecutive pair of points, however tightly the curve winds. Because resampling is generic over polylines, that applies to every motif -- yours included.

Writing your own takes one method::

from dataclasses import dataclass
from geomotif import Design, Motif, Path, register

@register("my-shape")
@dataclass(frozen=True, slots=True)
class MyShape(Motif):
    def build(self) -> Design:
        return Design((Path(((0.0, 0.0), (10.0, 10.0))),))

or none at all, if one of the bases in :mod:geomotif.bases already describes the kind of thing you are drawing -- then you write the maths and nothing else.

Motif classes live in :mod:geomotif.motifs, not here: the catalog is far too large for a flat namespace. This module exports the core model, the motif bases, the spacing curves, the transform layer and the registry -- the things you build with. See :mod:geomotif.core.registry for lookup by name.

Plotting helpers (require matplotlib, pip install 'geomotif[plot]') live in :mod:geomotif.plotting.

Modules:

Name Description
animate

Frames: the same design over time, or the same motif over a parameter.

bases

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

cli

The geomotif command line.

compose

Motifs made out of other motifs.

core

The engine: value types, sampling, spacing, transforms and the registry.

demo

Demo entry point: generate a few spirals and plot them with matplotlib.

explore

An explorable gallery: one page, sliders, and every picture already drawn.

io

Getting designs out of Python, and back in again.

motifs

The motif catalog.

plotting

Matplotlib helpers for looking at a design.

Classes:

Name Description
Curve

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

LatticeTiling

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

LSystemMotif

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

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.

SubstitutionTiling

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

Motif

Convenience base: implement :meth:build, inherit everything else.

SupportsBuild

Structural contract -- any object with build() works everywhere.

Range

A min/max/step bound for a parameter, as a field-metadata mapping.

ArcTable

Cumulative-length table over a polyline, and the inverse of it.

CircularSpacing

Circular (quarter-arc) easing -- abrupt at one end, flat at the other.

CompositeSpacing

Chain curves, feeding each one's output into the next.

CubicSpacing

Classic cubic easing (t ** 3).

ExponentialSpacing

Exponential easing with adjustable strength (dramatic bias).

LinearSpacing

Equal spacing between every point (the default, a "0 curve").

PowerSpacing

t ** exponent -- the general-purpose "by how much" control.

QuadraticSpacing

Classic quadratic easing (t ** 2).

ReversedSpacing

Mirror any curve: dense where the original was sparse, and vice versa.

SineSpacing

Sinusoidal easing -- a gentle, natural-feeling bias.

SmoothstepSpacing

Hermite smoothstep 3t^2 - 2t^3 -- inherently ease-in-out.

SpacingCurve

Base class for point-spacing curves.

TableSpacing

A curve drawn by hand, from arbitrary (t, eased) control points.

Style

How one stroke or one loose point is drawn.

Affine

A 2D affine transform, composable with @ and callable on points.

Bounds

An axis-aligned rectangle enclosing some geometry.

Design

The universal result: zero or more strokes plus zero or more loose points.

Path

One continuous polyline.

Functions:

Name Description
register

Register a motif class under name, returning it unchanged.

densify

Evaluate fn at evenly spaced parameters across domain.

resample

Return design resampled across all of its paths.

resample_path

Return path resampled to count points, or at a fixed step.

samples_for_turns

Return a sensible densification count for a curve spanning turns.

coerce_spacing

Normalize anything spacing-shaped into a :class:SpacingCurve.

by_layer

Split a design into one sub-design per layer, styles and all.

layer_names

Return the layers a design uses, in the order they first appear.

point_styles_of

Return one style per loose point, None where a point has none.

styled

Return design with a style laid over every stroke and loose point.

styles_of

Return one style per stroke, None where a stroke has none.

clip_to

Trim design to a rectangle, splitting paths that leave and re-enter.

fit_to

Scale and center design inside a canvas. See :meth:Design.fit.

jitter

Randomly displace every point, for controlled hand-drawn irregularity.

layer

Overlay designs into one. Equivalent to repeated +.

mirror_axis

Return design overlaid with its reflection across a line.

offset_path

Return a parallel copy of path, offset by distance to its left.

radial_repeat

Repeat design n times evenly around a point -- the mandala workhorse.

snap

Move every point onto the nearest line of a square grid.

symmetry_group

Apply a full cyclic Cn or dihedral Dn symmetry group.

tile

Repeat design on a rectangular lattice.

from_spec

Rebuild the motif a spec describes.

load_design

Read a design back from a JSON file written by :func:save_design.

load_spec

Read a spec file and return the motif it describes.

save_design

Write a design, strokes kept apart, and return the path written.

save_dxf

Write a design to a DXF file and return the path written.

save_gif

Write an animated GIF and return the path written.

save_jpeg

Render a design -- or re-encode a raster -- as JPEG and write it.

save_plotter_svg

Write a plotter-ready SVG and return the path written.

save_png

Render a design -- or re-encode a raster -- as a PNG and write it.

save_points

Write points to a file and return the path written.

save_spec

Write a motif's recipe to a JSON file and return the path written.

save_svg

Write a design to an SVG file and return the path written.

to_dxf

Render a design as a DXF R12 document.

to_gif

Render a sequence of designs as an animated GIF.

to_jpeg

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as JPEG.

to_plotter_svg

Render a design as an SVG measured in real millimeters.

to_png

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as PNG.

to_spec

Return the JSON-ready recipe for a motif, or for the design it built.

to_svg

Render a design as an SVG document.

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

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

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

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

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

Motif

Bases: ABC

Convenience base: implement :meth:build, inherit everything else.

The ABC exists to hand you :meth:generate and registration, not to police the type -- see :class:SupportsBuild if you would rather not inherit at all.

Methods:

Name Description
build

Return the design at its natural/native resolution.

generate

Build, then resample to count points (or a fixed step distance).

build abstractmethod

build() -> Design

Return the design at its natural/native resolution.

Source code in src/geomotif/core/motif.py
@abstractmethod
def build(self) -> Design:
    """Return the design at its natural/native resolution."""

generate

generate(count: int | None = None, *, step: float | None = None, spacing: SpacingLike | None = None, distribute: Distribution = 'length', by: Placement = 'length') -> Design

Build, then resample to count points (or a fixed step distance).

Parameters:

Name Type Description Default
count int

Total number of points to return. Must be >= 2.

None
step float

Fixed real distance between consecutive points, letting the count fall out of the geometry. Mutually exclusive with count.

None
spacing SpacingCurve or callable

Distribution of points along the path. Defaults to equal spacing.

None
distribute ('length', 'even', 'per_path')

How count is split across a multi-path design.

"length"
by ('length', 'parameter')

Place points by real distance along the curve (the default), or by even steps through its parametrization.

"length"

Returns:

Type Description
Design

The resampled design, ready to plot or export.

Source code in src/geomotif/core/motif.py
def generate(
    self,
    count: int | None = None,
    *,
    step: float | None = None,
    spacing: SpacingLike | None = None,
    distribute: Distribution = "length",
    by: Placement = "length",
) -> Design:
    """Build, then resample to ``count`` points (or a fixed ``step`` distance).

    Parameters
    ----------
    count : int, optional
        Total number of points to return. Must be >= 2.
    step : float, optional
        Fixed real distance between consecutive points, letting the count
        fall out of the geometry. Mutually exclusive with ``count``.
    spacing : SpacingCurve or callable, optional
        Distribution of points along the path. Defaults to equal spacing.
    distribute : {"length", "even", "per_path"}, optional
        How ``count`` is split across a multi-path design.
    by : {"length", "parameter"}, optional
        Place points by real distance along the curve (the default), or
        by even steps through its parametrization.

    Returns
    -------
    Design
        The resampled design, ready to plot or export.
    """
    return resample(
        self.build(),
        count,
        step=step,
        spacing=spacing,
        distribute=distribute,
        by=by,
    )

SupportsBuild

Bases: Protocol

Structural contract -- any object with build() works everywhere.

Following the :class:typing.SupportsInt convention, this is the structural twin of :class:Motif: anything that can build a design is accepted wherever a motif is, so nobody is ever forced to inherit.

Methods:

Name Description
build

Return the design at its natural resolution.

build

build() -> Design

Return the design at its natural resolution.

Source code in src/geomotif/core/motif.py
def build(self) -> Design:
    """Return the design at its natural resolution."""
    ...

Range dataclass

Range(min: float | None = None, max: float | None = None, step: float | None = None)

Bases: Mapping[str, float | None]

A min/max/step bound for a parameter, as a field-metadata mapping.

Any of the three may be None to leave that bound unset; a consumer then falls back to its own heuristic for the missing axis. The common case is to set all three, and the commonest still is Range(lo, hi, step=1) for an integer count.

Parameters:

Name Type Description Default
min float

Smallest sensible value, inclusive.

None
max float

Largest sensible value, inclusive.

None
step float

Granularity for an integer or quantized parameter. None means any value in the range is meaningful, which is the case for most floats.

None

Examples:

>>> from dataclasses import field
>>> field(default=5, metadata=Range(1, 50, step=1)).metadata.get("min")
1

ArcTable

ArcTable(points: Sequence[Point], *, closed: bool = False)

Cumulative-length table over a polyline, and the inverse of it.

Building the table is O(n). A lone "where is the point at distance d?" costs a binary search and one linear interpolation; asking for a whole run of increasing distances -- which is what resampling does -- walks the table once between them all instead, so the run is linear in the table rather than n log n. See :meth:points_at.

Methods:

Name Description
point_at

Return the point distance along the polyline, clamped to its ends.

point_at_fraction

Return the point at fraction s of the total length.

points_at

Return the point at each distance, in the order they were asked for.

segment

Return the part of the polyline lying between two distances.

points_at_fractions

Return the point at each fraction of the total length.

Attributes:

Name Type Description
total float

Total length of the polyline.

vertices tuple[Point, ...]

The measured points, including the closing vertex if closed.

Source code in src/geomotif/core/sampling.py
def __init__(self, points: Sequence[Point], *, closed: bool = False) -> None:
    vertices = _vertices(points, closed=closed)
    if not vertices:
        raise ValueError("cannot measure an empty polyline")
    cumulative = [0.0]
    for a, b in itertools.pairwise(vertices):
        cumulative.append(cumulative[-1] + math.dist(a, b))
    self._vertices = vertices
    self._cumulative = cumulative

total property

total: float

Total length of the polyline.

vertices property

vertices: tuple[Point, ...]

The measured points, including the closing vertex if closed.

point_at

point_at(distance: float) -> Point

Return the point distance along the polyline, clamped to its ends.

A zero-length polyline (every vertex coincident) always returns its single location rather than dividing by zero -- the degenerate case should collapse gracefully, not explode.

Source code in src/geomotif/core/sampling.py
def point_at(self, distance: float) -> Point:
    """Return the point ``distance`` along the polyline, clamped to its ends.

    A zero-length polyline (every vertex coincident) always returns its
    single location rather than dividing by zero -- the degenerate case
    should collapse gracefully, not explode.
    """
    cumulative = self._cumulative
    total = cumulative[-1]
    if total == 0.0:
        return self._vertices[0]
    if distance <= 0.0:
        return self._vertices[0]
    if distance >= total:
        return self._vertices[-1]

    j = bisect.bisect_left(cumulative, distance)
    if j <= 0:
        return self._vertices[0]
    segment = cumulative[j] - cumulative[j - 1]
    frac = 0.0 if segment == 0.0 else (distance - cumulative[j - 1]) / segment
    return _lerp(self._vertices[j - 1], self._vertices[j], frac)

point_at_fraction

point_at_fraction(s: float) -> Point

Return the point at fraction s of the total length.

Source code in src/geomotif/core/sampling.py
def point_at_fraction(self, s: float) -> Point:
    """Return the point at fraction ``s`` of the total length."""
    return self.point_at(s * self._cumulative[-1])

points_at

points_at(distances: Iterable[float]) -> tuple[Point, ...]

Return the point at each distance, in the order they were asked for.

Exactly what calling :meth:point_at on each in turn returns, and several times faster for the run of lookups that resampling actually performs. Those arrive in increasing order, so the segment holding one is at or after the segment that held the last, and the whole run walks the table once between them instead of binary-searching all of it every time.

Order is exploited, never assumed: a distance that goes backwards seeks again, so this has no precondition to get wrong and no fast and slow version to keep in agreement.

Source code in src/geomotif/core/sampling.py
def points_at(self, distances: Iterable[float]) -> tuple[Point, ...]:
    """Return the point at each distance, in the order they were asked for.

    Exactly what calling :meth:`point_at` on each in turn returns, and
    several times faster for the run of lookups that resampling actually
    performs. Those arrive in increasing order, so the segment holding one
    is at or after the segment that held the last, and the whole run walks
    the table once between them instead of binary-searching all of it every
    time.

    Order is exploited, never assumed: a distance that goes backwards seeks
    again, so this has no precondition to get wrong and no fast and slow
    version to keep in agreement.
    """
    cumulative = self._cumulative
    vertices = self._vertices
    total = cumulative[-1]
    first, final = vertices[0], vertices[-1]
    if total == 0.0:
        return tuple(first for _ in distances)

    limit = len(cumulative) - 1
    placed: list[Point] = []
    segment = 1
    for distance in distances:
        if distance <= 0.0:
            placed.append(first)
            continue
        if distance >= total:
            placed.append(final)
            continue
        if distance < cumulative[segment - 1]:
            segment = max(bisect.bisect_left(cumulative, distance), 1)
        while segment < limit and cumulative[segment] < distance:
            segment += 1
        start = cumulative[segment - 1]
        span = cumulative[segment] - start
        ax, ay = vertices[segment - 1]
        bx, by = vertices[segment]
        frac = 0.0 if span == 0.0 else (distance - start) / span
        placed.append((ax + (bx - ax) * frac, ay + (by - ay) * frac))
    return tuple(placed)

segment

segment(start: float, end: float) -> tuple[Point, ...]

Return the part of the polyline lying between two distances.

The ends are exact -- interpolated where they fall inside a segment -- and every vertex between them is kept as it was, so this is a piece of the polyline rather than a resampling of one. That is what an animation drawing itself on needs: the geometry so far, at the resolution it was built at.

Distances outside the polyline clamp to its ends, and a range that collapses to a point returns that one point.

Source code in src/geomotif/core/sampling.py
def segment(self, start: float, end: float) -> tuple[Point, ...]:
    """Return the part of the polyline lying between two distances.

    The ends are exact -- interpolated where they fall inside a segment --
    and every vertex between them is kept as it was, so this is a *piece*
    of the polyline rather than a resampling of one. That is what an
    animation drawing itself on needs: the geometry so far, at the
    resolution it was built at.

    Distances outside the polyline clamp to its ends, and a range that
    collapses to a point returns that one point.
    """
    if end < start:
        start, end = end, start
    start = max(start, 0.0)
    end = min(end, self.total)
    if end <= start:
        return (self.point_at(start),)
    kept = [self.point_at(start)]
    kept.extend(
        vertex
        for distance, vertex in zip(self._cumulative, self._vertices, strict=True)
        if start < distance < end
    )
    kept.append(self.point_at(end))
    return tuple(kept)

points_at_fractions

points_at_fractions(fractions: Iterable[float]) -> tuple[Point, ...]

Return the point at each fraction of the total length.

Source code in src/geomotif/core/sampling.py
def points_at_fractions(self, fractions: Iterable[float]) -> tuple[Point, ...]:
    """Return the point at each fraction of the total length."""
    total = self._cumulative[-1]
    return self.points_at([s * total for s in fractions])

CircularSpacing

CircularSpacing(mode: Mode = 'in')

Bases: _ModalCurve

Circular (quarter-arc) easing -- abrupt at one end, flat at the other.

Source code in src/geomotif/core/spacing.py
def __init__(self, mode: Mode = "in") -> None:
    if mode not in self.MODES:
        raise ValueError(f"mode must be one of {self.MODES}, got {mode!r}")
    self.mode = mode

CompositeSpacing

CompositeSpacing(*curves: SpacingLike)

Bases: SpacingCurve

Chain curves, feeding each one's output into the next.

CompositeSpacing(a, b)(t) == b(a(t)) -- written in application order, so it reads left to right. Composition of monotone [0, 1] -> [0, 1] maps is itself monotone with fixed endpoints, so the result is always a valid spacing curve.

Source code in src/geomotif/core/spacing.py
def __init__(self, *curves: SpacingLike) -> None:
    if not curves:
        raise ValueError("CompositeSpacing needs at least one curve")
    self.curves = tuple(coerce_spacing(c) for c in curves)

CubicSpacing

CubicSpacing(mode: Mode = 'in')

Bases: PowerSpacing

Classic cubic easing (t ** 3).

Source code in src/geomotif/core/spacing.py
def __init__(self, mode: Mode = "in") -> None:
    super().__init__(3.0, mode)

ExponentialSpacing

ExponentialSpacing(mode: Mode = 'in', strength: float = 10.0)

Bases: _ModalCurve

Exponential easing with adjustable strength (dramatic bias).

strength (default 10, the CSS/Penner standard) controls how extreme the clustering is; higher values pack points ever more tightly at the slow end. The curve is normalized so it still maps 0 -> 0 and 1 -> 1.

Source code in src/geomotif/core/spacing.py
def __init__(self, mode: Mode = "in", strength: float = 10.0) -> None:
    if strength <= 0:
        raise ValueError(f"strength must be > 0, got {strength}")
    super().__init__(mode)
    self.strength = strength

LinearSpacing

Bases: SpacingCurve

Equal spacing between every point (the default, a "0 curve").

PowerSpacing

PowerSpacing(exponent: float = 2.0, mode: Mode = 'in')

Bases: _ModalCurve

t ** exponent -- the general-purpose "by how much" control.

  • exponent == 1 -- equal spacing (identical to :class:LinearSpacing)
  • exponent > 1 -- with mode "in", spacing gradually increases; larger exponents exaggerate the effect
  • 0 < exponent < 1 -- the opposite bias

Combine with mode="out" to flip which end is dense.

Source code in src/geomotif/core/spacing.py
def __init__(self, exponent: float = 2.0, mode: Mode = "in") -> None:
    if exponent <= 0:
        raise ValueError(f"exponent must be > 0, got {exponent}")
    super().__init__(mode)
    self.exponent = exponent

QuadraticSpacing

QuadraticSpacing(mode: Mode = 'in')

Bases: PowerSpacing

Classic quadratic easing (t ** 2).

Source code in src/geomotif/core/spacing.py
def __init__(self, mode: Mode = "in") -> None:
    super().__init__(2.0, mode)

ReversedSpacing

ReversedSpacing(curve: SpacingLike)

Bases: SpacingCurve

Mirror any curve: dense where the original was sparse, and vice versa.

ReversedSpacing(f)(t) == 1 - f(1 - t), which is exactly the "out" of a modal curve's "in" -- but this works on curves that have no mode, including plain callables and :class:TableSpacing.

Source code in src/geomotif/core/spacing.py
def __init__(self, curve: SpacingLike) -> None:
    self.curve = coerce_spacing(curve)

SineSpacing

SineSpacing(mode: Mode = 'in')

Bases: _ModalCurve

Sinusoidal easing -- a gentle, natural-feeling bias.

Source code in src/geomotif/core/spacing.py
def __init__(self, mode: Mode = "in") -> None:
    if mode not in self.MODES:
        raise ValueError(f"mode must be one of {self.MODES}, got {mode!r}")
    self.mode = mode

SmoothstepSpacing

Bases: SpacingCurve

Hermite smoothstep 3t^2 - 2t^3 -- inherently ease-in-out.

Spacing grows toward the middle of the path and shrinks again toward the end, with perfectly smooth acceleration.

SpacingCurve

Bases: ABC

Base class for point-spacing curves.

Subclasses must implement :meth:ease, mapping [0, 1] -> [0, 1] monotonically with the endpoints fixed.

Methods:

Name Description
ease

Map a fraction of the way along the curve to a fraction of its length.

ease abstractmethod

ease(t: float) -> float

Map a fraction of the way along the curve to a fraction of its length.

Source code in src/geomotif/core/spacing.py
@abstractmethod
def ease(self, t: float) -> float:
    """Map a fraction of the way along the curve to a fraction of its length."""

TableSpacing

TableSpacing(points: Sequence[tuple[float, float]])

Bases: SpacingCurve

A curve drawn by hand, from arbitrary (t, eased) control points.

Interpolation between control points is linear. That is a deliberate choice over a smoother spline: linear interpolation of monotone data is exactly monotone, whereas cubic fits can overshoot and hand back a curve that walks backwards -- which shows up as points in the wrong order.

Parameters:

Name Type Description Default
points sequence of (float, float)

Control points in [0, 1] x [0, 1]. (0, 0) and (1, 1) are supplied automatically when absent, so only the interesting middle needs listing. Both coordinates must be non-decreasing.

required

Examples:

A curve that spends the first half of its length on the first quarter of the path::

TableSpacing([(0.5, 0.25)])
Source code in src/geomotif/core/spacing.py
def __init__(self, points: Sequence[tuple[float, float]]) -> None:
    table = sorted((float(t), float(v)) for t, v in points)
    for t, v in table:
        if not 0.0 <= t <= 1.0 or not 0.0 <= v <= 1.0:
            raise ValueError(f"control points must lie in [0, 1] x [0, 1], got ({t}, {v})")
    if not table or table[0][0] > 0.0:
        table.insert(0, (0.0, 0.0))
    if table[-1][0] < 1.0:
        table.append((1.0, 1.0))
    for (t0, v0), (t1, v1) in itertools.pairwise(table):
        if t1 < t0 or v1 < v0:
            raise ValueError(
                f"control points must be non-decreasing in both axes, "
                f"got ({t0}, {v0}) then ({t1}, {v1})"
            )
    self.table = tuple(table)

Style dataclass

Style(layer: str | None = None, stroke: str | None = None, width: float | None = None, fill: str | None = None)

How one stroke or one loose point is drawn.

Every field is optional and None means "not stated", which is not the same as a default: an unstated color takes whatever the writer was told to use, so a style that names only a layer still draws in the document's own ink.

Parameters:

Name Type Description Default
layer str

Which layer the geometry belongs on. SVG writes these as the labeled groups Inkscape and vpype read; DXF writes them as real layers, so the name has to be one DXF permits.

None
stroke str

Line color, as any CSS color string. DXF has no notion of an arbitrary color, so its writer maps the seven it can name and leaves the rest to the layer.

None
width float

Stroke width, in the units the writer is working in. Must be > 0.

None
fill str

Fill color for closed paths. Line art rarely wants one, which is why it is not the default.

None

Methods:

Name Description
merged

Return this style with everything other states laid over it.

merged

merged(other: Style | None) -> Style

Return this style with everything other states laid over it.

Silence loses: a field other leaves at None keeps this style's value, which is what makes styled composable -- setting a color later must not quietly clear the layer set earlier.

Source code in src/geomotif/core/style.py
def merged(self, other: Style | None) -> Style:
    """Return this style with everything ``other`` states laid over it.

    Silence loses: a field ``other`` leaves at ``None`` keeps this style's
    value, which is what makes ``styled`` composable -- setting a color
    later must not quietly clear the layer set earlier.
    """
    if other is None:
        return self
    stated = {f.name: getattr(other, f.name) for f in fields(other)}
    return replace(self, **{k: v for k, v in stated.items() if v is not None})

Affine dataclass

Affine(a: float = 1.0, b: float = 0.0, c: float = 0.0, d: float = 1.0, e: float = 0.0, f: float = 0.0)

A 2D affine transform, composable with @ and callable on points.

Defaults to the identity, so Affine() is a no-op you can build on.

Methods:

Name Description
identity

Return the transform that changes nothing.

translate

Move by dx horizontally and dy vertically.

rotate

Rotate by angle radians, counter-clockwise in y-up coordinates.

scale

Scale by sx horizontally and sy vertically (sy defaults to sx).

mirror

Reflect across the line at angle radians passing through through.

shear

Slant by kx along x per unit y, and ky along y per unit x.

inverse

Return the transform that undoes this one.

Attributes:

Name Type Description
determinant float

Signed area scale factor; negative when the transform reflects.

determinant property

determinant: float

Signed area scale factor; negative when the transform reflects.

identity classmethod

identity() -> Self

Return the transform that changes nothing.

Source code in src/geomotif/core/transform.py
@classmethod
def identity(cls) -> Self:
    """Return the transform that changes nothing."""
    return cls()

translate classmethod

translate(dx: float, dy: float) -> Self

Move by dx horizontally and dy vertically.

Source code in src/geomotif/core/transform.py
@classmethod
def translate(cls, dx: float, dy: float) -> Self:
    """Move by ``dx`` horizontally and ``dy`` vertically."""
    return cls(e=dx, f=dy)

rotate classmethod

rotate(angle: float, *, about: Point = (0.0, 0.0)) -> Self

Rotate by angle radians, counter-clockwise in y-up coordinates.

Source code in src/geomotif/core/transform.py
@classmethod
def rotate(cls, angle: float, *, about: Point = (0.0, 0.0)) -> Self:
    """Rotate by ``angle`` radians, counter-clockwise in y-up coordinates."""
    cos, sin = math.cos(angle), math.sin(angle)
    cx, cy = about
    return cls(
        a=cos,
        b=sin,
        c=-sin,
        d=cos,
        e=cx - cx * cos + cy * sin,
        f=cy - cx * sin - cy * cos,
    )

scale classmethod

scale(sx: float, sy: float | None = None, *, about: Point = (0.0, 0.0)) -> Self

Scale by sx horizontally and sy vertically (sy defaults to sx).

Source code in src/geomotif/core/transform.py
@classmethod
def scale(cls, sx: float, sy: float | None = None, *, about: Point = (0.0, 0.0)) -> Self:
    """Scale by ``sx`` horizontally and ``sy`` vertically (``sy`` defaults to ``sx``)."""
    if sy is None:
        sy = sx
    cx, cy = about
    return cls(a=sx, d=sy, e=cx - cx * sx, f=cy - cy * sy)

mirror classmethod

mirror(angle: float = 0.0, *, through: Point = (0.0, 0.0)) -> Self

Reflect across the line at angle radians passing through through.

Source code in src/geomotif/core/transform.py
@classmethod
def mirror(cls, angle: float = 0.0, *, through: Point = (0.0, 0.0)) -> Self:
    """Reflect across the line at ``angle`` radians passing through ``through``."""
    cos, sin = math.cos(2.0 * angle), math.sin(2.0 * angle)
    cx, cy = through
    return cls(
        a=cos,
        b=sin,
        c=sin,
        d=-cos,
        e=cx - cx * cos - cy * sin,
        f=cy - cx * sin + cy * cos,
    )

shear classmethod

shear(kx: float, ky: float = 0.0) -> Self

Slant by kx along x per unit y, and ky along y per unit x.

Source code in src/geomotif/core/transform.py
@classmethod
def shear(cls, kx: float, ky: float = 0.0) -> Self:
    """Slant by ``kx`` along x per unit y, and ``ky`` along y per unit x."""
    return cls(c=kx, b=ky)

inverse

inverse() -> Affine

Return the transform that undoes this one.

Raises:

Type Description
ValueError

If the transform is singular (a zero scale factor, say), which collapses the plane onto a line and cannot be undone.

Source code in src/geomotif/core/transform.py
def inverse(self) -> Affine:
    """Return the transform that undoes this one.

    Raises
    ------
    ValueError
        If the transform is singular (a zero scale factor, say), which
        collapses the plane onto a line and cannot be undone.
    """
    det = self.determinant
    if det == 0.0:
        raise ValueError(f"transform is singular and cannot be inverted: {self!r}")
    return Affine(
        a=self.d / det,
        b=-self.b / det,
        c=-self.c / det,
        d=self.a / det,
        e=(self.c * self.f - self.d * self.e) / det,
        f=(self.b * self.e - self.a * self.f) / det,
    )

Bounds dataclass

Bounds(min_x: float, min_y: float, max_x: float, max_y: float)

An axis-aligned rectangle enclosing some geometry.

Methods:

Name Description
from_points

Return the tightest bounds containing every point.

union

Return the smallest bounds containing both rectangles.

padded

Return these bounds grown by amount on every side.

Attributes:

Name Type Description
width float

Horizontal extent.

height float

Vertical extent.

center Point

Midpoint of the rectangle.

width property

width: float

Horizontal extent.

height property

height: float

Vertical extent.

center property

center: Point

Midpoint of the rectangle.

from_points classmethod

from_points(points: Iterable[Point]) -> Bounds

Return the tightest bounds containing every point.

Raises:

Type Description
ValueError

If points is empty; an empty set has no meaningful extent, and returning a zero rectangle at the origin would be a lie that quietly skews every later fit and clip.

Source code in src/geomotif/core/types.py
@classmethod
def from_points(cls, points: Iterable[Point]) -> Bounds:
    """Return the tightest bounds containing every point.

    Raises
    ------
    ValueError
        If ``points`` is empty; an empty set has no meaningful extent,
        and returning a zero rectangle at the origin would be a lie that
        quietly skews every later ``fit`` and ``clip``.
    """
    xs: list[float] = []
    ys: list[float] = []
    for x, y in points:
        xs.append(x)
        ys.append(y)
    if not xs:
        raise ValueError("cannot compute bounds of an empty point set")
    return cls(min(xs), min(ys), max(xs), max(ys))

union

union(other: Bounds) -> Bounds

Return the smallest bounds containing both rectangles.

Source code in src/geomotif/core/types.py
def union(self, other: Bounds) -> Bounds:
    """Return the smallest bounds containing both rectangles."""
    return Bounds(
        min(self.min_x, other.min_x),
        min(self.min_y, other.min_y),
        max(self.max_x, other.max_x),
        max(self.max_y, other.max_y),
    )

padded

padded(amount: float) -> Bounds

Return these bounds grown by amount on every side.

Source code in src/geomotif/core/types.py
def padded(self, amount: float) -> Bounds:
    """Return these bounds grown by ``amount`` on every side."""
    return Bounds(
        self.min_x - amount,
        self.min_y - amount,
        self.max_x + amount,
        self.max_y + amount,
    )

Design dataclass

Design(paths: tuple[Path, ...] = (), points: tuple[Point, ...] = (), meta: Mapping[str, object] = EMPTY_META)

The universal result: zero or more strokes plus zero or more loose points.

meta carries the motif name and its resolved parameters (including any resolved random seed), which is what makes a design reproducible, serializable to a spec file, and self-labelling in the gallery.

Methods:

Name Description
transformed

Return this design with m applied to every point.

flipped_y

Return this design mirrored about the x-axis.

resampled

Return this design resampled to count points (or a fixed step).

snapped

Return this design with every point moved onto a grid of step.

fit

Return this design scaled and centered inside a width x height canvas.

Attributes:

Name Type Description
bounds Bounds

Bounds over every point in every path plus the loose points.

bounds property

bounds: Bounds

Bounds over every point in every path plus the loose points.

transformed

transformed(m: Affine) -> Design

Return this design with m applied to every point.

Source code in src/geomotif/core/types.py
def transformed(self, m: Affine) -> Design:
    """Return this design with ``m`` applied to every point."""
    paths = tuple(replace(path, points=tuple(m(p) for p in path.points)) for path in self.paths)
    return Design(paths, tuple(m(p) for p in self.points), self.meta)

flipped_y

flipped_y() -> Design

Return this design mirrored about the x-axis.

The y-up/y-down question is a property of the target coordinate space, not of any motif, which is why it lives here rather than as a flag on every builder.

Source code in src/geomotif/core/types.py
def flipped_y(self) -> Design:
    """Return this design mirrored about the x-axis.

    The y-up/y-down question is a property of the target coordinate space,
    not of any motif, which is why it lives here rather than as a flag on
    every builder.
    """
    paths = tuple(
        replace(path, points=tuple((x, -y) for x, y in path.points)) for path in self.paths
    )
    return Design(paths, tuple((x, -y) for x, y in self.points), self.meta)

resampled

resampled(count: int | None = None, *, step: float | None = None, spacing: SpacingLike | None = None, distribute: Distribution = 'length') -> Design

Return this design resampled to count points (or a fixed step).

See :func:geomotif.core.sampling.resample for the full contract.

Source code in src/geomotif/core/types.py
def resampled(
    self,
    count: int | None = None,
    *,
    step: float | None = None,
    spacing: SpacingLike | None = None,
    distribute: Distribution = "length",
) -> Design:
    """Return this design resampled to ``count`` points (or a fixed ``step``).

    See :func:`geomotif.core.sampling.resample` for the full contract.
    """
    # Imported here rather than at module scope: the sampling engine is
    # built on top of these types, so a top-level import would be circular.
    from .sampling import resample

    return resample(self, count, step=step, spacing=spacing, distribute=distribute)

snapped

snapped(step: float = 1.0, *, mode: SnapMode = 'half-even', drop_duplicates: bool = True) -> Design

Return this design with every point moved onto a grid of step.

Rounding the geometry rather than each file as it is written, so every exporter agrees and a plot shows what the file will hold. Defaults to whole units.

See :func:geomotif.core.transform.snap for the full contract.

Source code in src/geomotif/core/types.py
def snapped(
    self,
    step: float = 1.0,
    *,
    mode: SnapMode = "half-even",
    drop_duplicates: bool = True,
) -> Design:
    """Return this design with every point moved onto a grid of ``step``.

    Rounding the geometry rather than each file as it is written, so every
    exporter agrees and a plot shows what the file will hold. Defaults to
    whole units.

    See :func:`geomotif.core.transform.snap` for the full contract.
    """
    # Imported here rather than at module scope: the transform layer is
    # built on top of these types, so a top-level import would be circular.
    from .transform import snap

    return snap(self, step, mode=mode, drop_duplicates=drop_duplicates)

fit

fit(width: float, height: float, *, padding: float = 0.0, flip_y: bool = False) -> Design

Return this design scaled and centered inside a width x height canvas.

Scaling is uniform, so the design is never distorted; it is centered in whichever axis has slack. The result sits in [0, width] x [0, height], with flip_y=True producing y-down (screen/SVG) coordinates.

Parameters:

Name Type Description Default
width float

Canvas size. Both must be positive.

required
height float

Canvas size. Both must be positive.

required
padding float

Margin reserved on all four sides.

0.0
flip_y bool

Mirror vertically so y increases downward.

False

Returns:

Type Description
Design

The fitted design. A design with no extent in either axis (a single point, or a perfectly vertical line) is translated but never scaled, since there is no finite scale that fills a canvas from nothing.

Source code in src/geomotif/core/types.py
def fit(
    self,
    width: float,
    height: float,
    *,
    padding: float = 0.0,
    flip_y: bool = False,
) -> Design:
    """Return this design scaled and centered inside a ``width`` x ``height`` canvas.

    Scaling is uniform, so the design is never distorted; it is centered
    in whichever axis has slack. The result sits in ``[0, width] x
    [0, height]``, with ``flip_y=True`` producing y-down (screen/SVG)
    coordinates.

    Parameters
    ----------
    width, height : float
        Canvas size. Both must be positive.
    padding : float, optional
        Margin reserved on all four sides.
    flip_y : bool, optional
        Mirror vertically so y increases downward.

    Returns
    -------
    Design
        The fitted design. A design with no extent in either axis (a
        single point, or a perfectly vertical line) is translated but
        never scaled, since there is no finite scale that fills a canvas
        from nothing.
    """
    if width <= 0 or height <= 0:
        raise ValueError(f"width and height must be > 0, got {width}x{height}")
    if padding < 0:
        raise ValueError(f"padding must be >= 0, got {padding}")
    inner_w = width - 2.0 * padding
    inner_h = height - 2.0 * padding
    if inner_w <= 0 or inner_h <= 0:
        raise ValueError(f"padding {padding} leaves no room inside {width}x{height}")

    b = self.bounds
    scales = [inner_w / b.width] if b.width > 0 else []
    if b.height > 0:
        scales.append(inner_h / b.height)
    scale = min(scales) if scales else 1.0

    offset_x = padding + (inner_w - b.width * scale) / 2.0
    offset_y = padding + (inner_h - b.height * scale) / 2.0

    def place(p: Point) -> Point:
        x = offset_x + (p[0] - b.min_x) * scale
        y = offset_y + (p[1] - b.min_y) * scale
        return (x, height - y) if flip_y else (x, y)

    paths = tuple(
        replace(path, points=tuple(place(p) for p in path.points)) for path in self.paths
    )
    return Design(paths, tuple(place(p) for p in self.points), self.meta)

Path dataclass

Path(points: tuple[Point, ...], closed: bool = False)

One continuous polyline.

closed means the last point connects back to the first. The closing segment is implied, never stored, so a closed path's points are never duplicated at the seam.

Points are normalized to a tuple of finite (float, float) pairs at construction, so any sequence of pairs may be passed in.

Attributes:

Name Type Description
length float

Total polyline length, including the closing segment if closed.

bounds Bounds

Tightest rectangle containing every vertex.

length property

length: float

Total polyline length, including the closing segment if closed.

A two-point "closed" path is treated as a single open segment: its closing segment retraces the one it already has, and counting that twice reports a length no plotter would ever draw.

bounds property

bounds: Bounds

Tightest rectangle containing every vertex.

register

register(name: str | None = None, *, family: str | None = None, requires: str | None = None, example: Mapping[str, object] | None = None) -> Callable[[type[MotifT]], type[MotifT]]

Register a motif class under name, returning it unchanged.

Parameters:

Name Type Description Default
name str

Registry key. Derived from the class name in kebab-case when omitted, so GoldenSpiral becomes golden-spiral.

None
family str

Grouping for geomotif list and the gallery, e.g. "spiral".

None
requires str

Name of an optional dependency the motif needs, e.g. "scipy". Listings report such motifs as unavailable rather than failing to import when the extra is missing.

None
example mapping

Constructor arguments producing a representative instance -- what the gallery renders and what the conformance suite exercises. Required for motifs with parameters that have no default, since those cannot be instantiated any other way.

None

Examples:

::

@register("rose", family="polar", example={"k": 5})
@dataclass(frozen=True, slots=True)
class Rose(PolarMotif): ...
Source code in src/geomotif/core/registry.py
def register(
    name: str | None = None,
    *,
    family: str | None = None,
    requires: str | None = None,
    example: Mapping[str, object] | None = None,
) -> Callable[[type[MotifT]], type[MotifT]]:
    """Register a motif class under ``name``, returning it unchanged.

    Parameters
    ----------
    name : str, optional
        Registry key. Derived from the class name in kebab-case when
        omitted, so ``GoldenSpiral`` becomes ``golden-spiral``.
    family : str, optional
        Grouping for ``geomotif list`` and the gallery, e.g. ``"spiral"``.
    requires : str, optional
        Name of an optional dependency the motif needs, e.g. ``"scipy"``.
        Listings report such motifs as unavailable rather than failing to
        import when the extra is missing.
    example : mapping, optional
        Constructor arguments producing a representative instance -- what
        the gallery renders and what the conformance suite exercises.
        Required for motifs with parameters that have no default, since
        those cannot be instantiated any other way.

    Examples
    --------
    ::

        @register("rose", family="polar", example={"k": 5})
        @dataclass(frozen=True, slots=True)
        class Rose(PolarMotif): ...
    """

    def decorate(cls: type[MotifT]) -> type[MotifT]:
        key = name if name is not None else _derive_name(cls)
        existing = _REGISTRY.get(key)
        if existing is not None and existing.cls is not cls:
            raise ValueError(
                f"motif name {key!r} is already registered to "
                f"{existing.cls.__module__}.{existing.cls.__qualname__}; "
                f"pass a different name to @register"
            )
        if _reserves_the_name_key(cls):
            raise ValueError(
                f"{cls.__qualname__} has a parameter called {NAME_KEY!r}, which is "
                f"the key spec() reserves for a design's own registered name. The "
                f"two would collide in Design.meta and the design could not be "
                f"rebuilt from it -- rename the parameter; the composers in "
                f"geomotif.compose call theirs 'unit'"
            )
        _REGISTRY[key] = _Entry(cls, family, requires, MappingProxyType(dict(example or {})))
        return cls

    return decorate

densify

densify(fn: Callable[[float], Point], *, samples: int, domain: tuple[float, float] = (0.0, 1.0)) -> tuple[Point, ...]

Evaluate fn at evenly spaced parameters across domain.

Returns samples + 1 points, so both endpoints of the domain are included and the result contains exactly samples segments.

Source code in src/geomotif/core/sampling.py
def densify(
    fn: Callable[[float], Point],
    *,
    samples: int,
    domain: tuple[float, float] = (0.0, 1.0),
) -> tuple[Point, ...]:
    """Evaluate ``fn`` at evenly spaced parameters across ``domain``.

    Returns ``samples + 1`` points, so both endpoints of the domain are
    included and the result contains exactly ``samples`` segments.
    """
    if samples < 1:
        raise ValueError(f"samples must be >= 1, got {samples}")
    lo, hi = domain
    span = hi - lo
    return tuple(fn(lo + span * (j / samples)) for j in range(samples + 1))

resample

resample(design: Design, count: int | None = None, *, step: float | None = None, spacing: SpacingLike | None = None, distribute: Distribution = 'length', by: Placement = 'length') -> Design

Return design resampled across all of its paths.

Loose points are passed through untouched: they are already exactly the points the motif meant, with no curve to redistribute them along.

Parameters:

Name Type Description Default
design Design

The design to resample.

required
count int

Total number of points. How it is split across paths depends on distribute. Mutually exclusive with step.

None
step float

Fixed distance between consecutive points, applied independently to every path. distribute is irrelevant in this mode.

None
spacing SpacingCurve or callable

Distribution of points along each path.

None
distribute ('length', 'even', 'per_path')

How a total count is spread over a multi-path design:

  • "length" (default) -- proportional to each path's arc length, giving uniform visual density across the whole design
  • "even" -- count // len(paths) on each path, giving uniform per-stroke detail regardless of stroke length
  • "per_path" -- count points on each path
"length"
by ('length', 'parameter')

Placement mode along each individual path; see :func:resample_path.

"length"

Returns:

Type Description
Design

A new design with the same loose points and metadata.

Source code in src/geomotif/core/sampling.py
def resample(
    design: Design,
    count: int | None = None,
    *,
    step: float | None = None,
    spacing: SpacingLike | None = None,
    distribute: Distribution = "length",
    by: Placement = "length",
) -> Design:
    """Return ``design`` resampled across all of its paths.

    Loose points are passed through untouched: they are already exactly the
    points the motif meant, with no curve to redistribute them along.

    Parameters
    ----------
    design : Design
        The design to resample.
    count : int, optional
        Total number of points. How it is split across paths depends on
        ``distribute``. Mutually exclusive with ``step``.
    step : float, optional
        Fixed distance between consecutive points, applied independently to
        every path. ``distribute`` is irrelevant in this mode.
    spacing : SpacingCurve or callable, optional
        Distribution of points along each path.
    distribute : {"length", "even", "per_path"}, optional
        How a total ``count`` is spread over a multi-path design:

        * ``"length"`` (default) -- proportional to each path's arc length,
          giving uniform visual density across the whole design
        * ``"even"`` -- ``count // len(paths)`` on each path, giving uniform
          per-stroke detail regardless of stroke length
        * ``"per_path"`` -- ``count`` points on *each* path
    by : {"length", "parameter"}, optional
        Placement mode along each individual path; see :func:`resample_path`.

    Returns
    -------
    Design
        A new design with the same loose points and metadata.
    """
    if not design.paths:
        return design

    if step is not None:
        paths = tuple(
            resample_path(path, step=step, spacing=spacing, by=by) for path in design.paths
        )
        return Design(paths, design.points, design.meta)

    if count is None:
        raise ValueError("pass exactly one of count= or step=")
    # Checked here as well as in resample_path because the apportionment
    # below may legitimately hand a single point to a short path -- but a
    # whole design of one point is a caller mistake, not a design decision.
    if count < 2:
        raise ValueError(f"count must be >= 2, got {count}")

    match distribute:
        case "length":
            allocation = _allocate(count, [path.length for path in design.paths])
        case "even":
            per_path = count // len(design.paths)
            if per_path < 2:
                raise ValueError(
                    f"distribute='even' gives {per_path} point(s) per path for count={count} "
                    f"across {len(design.paths)} paths; raise count to at least "
                    f"{2 * len(design.paths)} or use distribute='length'"
                )
            allocation = [per_path] * len(design.paths)
        case "per_path":
            allocation = [count] * len(design.paths)
        case _:
            raise ValueError(
                f"distribute must be 'length', 'even' or 'per_path', got {distribute!r}"
            )

    out: list[Path] = []
    # Which source stroke each surviving one came from: a path allocated no
    # points is dropped entirely, and its style has to go with it rather than
    # slide onto its neighbour.
    kept: list[int] = []
    for index, (path, n) in enumerate(zip(design.paths, allocation, strict=True)):
        match n:
            case 0:
                continue
            case 1:
                out.append(replace(path, points=(path.points[0],)))
            case _:
                out.append(resample_path(path, n, spacing=spacing, by=by))
        kept.append(index)
    return Design(tuple(out), design.points, select_styles(design.meta, paths=kept))

resample_path

resample_path(path: Path, count: int | None = None, *, step: float | None = None, spacing: SpacingLike | None = None, by: Placement = 'length') -> Path

Return path resampled to count points, or at a fixed step.

Parameters:

Name Type Description Default
path Path

The polyline to resample.

required
count int

Total number of points to return. Must be >= 2. Mutually exclusive with step.

None
step float

Fixed real distance between consecutive points; the count falls out of the geometry. Any remainder shorter than step at the end of the path is dropped, so gaps are never uneven. This is the mode you want for plotter output and dot placement.

None
spacing SpacingCurve or callable

Distribution of points along the path. Defaults to equal spacing. Cannot be combined with step, which is by definition uniform.

None
by ('length', 'parameter')

"length" (default) places points by real distance along the curve. "parameter" advances evenly through the path's own vertices instead, which compresses spacing wherever the curve tightens -- occasionally useful as a design effect. Only meaningful with count.

"length"

Returns:

Type Description
Path

The resampled path, preserving closed.

Source code in src/geomotif/core/sampling.py
def resample_path(
    path: Path,
    count: int | None = None,
    *,
    step: float | None = None,
    spacing: SpacingLike | None = None,
    by: Placement = "length",
) -> Path:
    """Return ``path`` resampled to ``count`` points, or at a fixed ``step``.

    Parameters
    ----------
    path : Path
        The polyline to resample.
    count : int, optional
        Total number of points to return. Must be >= 2. Mutually exclusive
        with ``step``.
    step : float, optional
        Fixed real distance between consecutive points; the count falls out
        of the geometry. Any remainder shorter than ``step`` at the end of
        the path is dropped, so gaps are never uneven. This is the mode you
        want for plotter output and dot placement.
    spacing : SpacingCurve or callable, optional
        Distribution of points along the path. Defaults to equal spacing.
        Cannot be combined with ``step``, which is by definition uniform.
    by : {"length", "parameter"}, optional
        ``"length"`` (default) places points by real distance along the
        curve. ``"parameter"`` advances evenly through the path's own
        vertices instead, which compresses spacing wherever the curve
        tightens -- occasionally useful as a design effect. Only meaningful
        with ``count``.

    Returns
    -------
    Path
        The resampled path, preserving ``closed``.
    """
    if (count is None) == (step is None):
        raise ValueError("pass exactly one of count= or step=")
    if by not in ("length", "parameter"):
        raise ValueError(f"by must be 'length' or 'parameter', got {by!r}")
    if not path.points:
        raise ValueError("cannot resample an empty path")

    if step is not None:
        if step <= 0:
            raise ValueError(f"step must be > 0, got {step}")
        if spacing is not None:
            raise ValueError(
                "step= places points at a fixed distance, so it cannot be combined "
                "with spacing=; pass count= with spacing= instead"
            )
        if by != "length":
            raise ValueError("step= measures real distance, so by='parameter' is meaningless")
        table = ArcTable(path.points, closed=path.closed)
        if table.total == 0.0:
            return replace(path, points=(path.points[0],))
        howmany = int(table.total // step) + 1
        return replace(path, points=table.points_at([i * step for i in range(howmany)]))

    # The exclusivity check above already guarantees this, but it is not a
    # narrowing the type checker can follow, so restate it.
    if count is None:
        raise ValueError("pass exactly one of count= or step=")
    if count < 2:
        raise ValueError(f"count must be >= 2, got {count}")

    fractions = _fractions(count, closed=path.closed, spacing=spacing)

    if by == "parameter":
        vertices = _vertices(path.points, closed=path.closed)
        return replace(path, points=tuple(_point_at_index_fraction(vertices, s) for s in fractions))

    table = ArcTable(path.points, closed=path.closed)
    if table.total == 0.0:
        # Every vertex is the same place: emit that place, count times, rather
        # than failing. Degenerate input should degrade, not raise.
        return replace(path, points=(path.points[0],) * count)
    return replace(path, points=table.points_at_fractions(fractions))

samples_for_turns

samples_for_turns(turns: float) -> int

Return a sensible densification count for a curve spanning turns.

Sample density has to scale with how much the curve actually bends, or a tightly wound motif is measured by a polyline that cuts every corner. This is the adaptive heuristic the spiral generator used, exposed so every motif can share one answer.

Source code in src/geomotif/core/sampling.py
def samples_for_turns(turns: float) -> int:
    """Return a sensible densification count for a curve spanning ``turns``.

    Sample density has to scale with how much the curve actually bends, or a
    tightly wound motif is measured by a polyline that cuts every corner. This
    is the adaptive heuristic the spiral generator used, exposed so every
    motif can share one answer.
    """
    return max(_MIN_SAMPLES, int(_SAMPLES_PER_TURN * (abs(turns) + 1.0)))

coerce_spacing

coerce_spacing(spacing: SpacingLike | None) -> SpacingCurve

Normalize anything spacing-shaped into a :class:SpacingCurve.

None means :class:LinearSpacing. This is the single place the library decides what counts as a spacing curve, so every entry point accepts exactly the same things and fails the same way.

Raises:

Type Description
TypeError

If spacing is neither a curve, a callable, nor None.

Source code in src/geomotif/core/spacing.py
def coerce_spacing(spacing: SpacingLike | None) -> SpacingCurve:
    """Normalize anything spacing-shaped into a :class:`SpacingCurve`.

    ``None`` means :class:`LinearSpacing`. This is the single place the
    library decides what counts as a spacing curve, so every entry point
    accepts exactly the same things and fails the same way.

    Raises
    ------
    TypeError
        If ``spacing`` is neither a curve, a callable, nor ``None``.
    """
    if spacing is None:
        return LinearSpacing()
    if isinstance(spacing, SpacingCurve):
        return spacing
    if callable(spacing):
        return _CallableSpacing(spacing)
    raise TypeError(
        f"spacing must be a SpacingCurve or a callable mapping [0, 1] -> [0, 1], got {spacing!r}"
    )

by_layer

by_layer(design: Design) -> dict[str | None, Design]

Split a design into one sub-design per layer, styles and all.

Returns:

Type Description
dict

Keyed by layer name, in first-appearance order, with None holding whatever carries no layer. The key is None rather than some stand-in name because each writer has its own idea of what the unnamed layer is called -- "0" in DXF, 1 in vpype -- and picking one here would be wrong somewhere else.

Source code in src/geomotif/core/style.py
def by_layer(design: Design) -> dict[str | None, Design]:
    """Split a design into one sub-design per layer, styles and all.

    Returns
    -------
    dict
        Keyed by layer name, in first-appearance order, with ``None`` holding
        whatever carries no layer. The key is ``None`` rather than some
        stand-in name because each writer has its own idea of what the
        unnamed layer is called -- ``"0"`` in DXF, ``1`` in ``vpype`` -- and
        picking one here would be wrong somewhere else.
    """
    path_styles = styles_of(design)
    point_styles = point_styles_of(design)

    order: dict[str | None, None] = {}
    for style in (*path_styles, *point_styles):
        order.setdefault(style.layer if style is not None else None, None)

    split: dict[str | None, Design] = {}
    for name in order:
        paths = [i for i, style in enumerate(path_styles) if _layer_of(style) == name]
        points = [i for i, style in enumerate(point_styles) if _layer_of(style) == name]
        split[name] = Design(
            tuple(design.paths[i] for i in paths),
            tuple(design.points[i] for i in points),
            select_styles(design.meta, paths=paths, points=points),
        )
    return split

layer_names

layer_names(design: Design) -> tuple[str, ...]

Return the layers a design uses, in the order they first appear.

First appearance rather than alphabetical: layer order is drawing order, and a plotter changes pens in the order the file lists them.

Source code in src/geomotif/core/style.py
def layer_names(design: Design) -> tuple[str, ...]:
    """Return the layers a design uses, in the order they first appear.

    First appearance rather than alphabetical: layer order is drawing order,
    and a plotter changes pens in the order the file lists them.
    """
    seen: dict[str, None] = {}
    for style in (*styles_of(design), *point_styles_of(design)):
        if style is not None and style.layer is not None:
            seen.setdefault(style.layer, None)
    return tuple(seen)

point_styles_of

point_styles_of(design: Design) -> tuple[Style | None, ...]

Return one style per loose point, None where a point has none.

Source code in src/geomotif/core/style.py
def point_styles_of(design: Design) -> tuple[Style | None, ...]:
    """Return one style per loose point, ``None`` where a point has none."""
    return _aligned(design.meta, POINT_STYLE_KEY, len(design.points))

styled

styled(design: Design, style: Style | None = None, *, layer: str | None = None, stroke: str | None = None, width: float | None = None, fill: str | None = None) -> Design

Return design with a style laid over every stroke and loose point.

Parameters:

Name Type Description Default
design Design

What to style. Returned unchanged if nothing is actually stated.

required
style Style

A whole style to apply.

None
layer str | None

Individual fields, applied over style where both are given. This is the form to reach for: styled(design, layer="pen1").

None
stroke str | None

Individual fields, applied over style where both are given. This is the form to reach for: styled(design, layer="pen1").

None
width str | None

Individual fields, applied over style where both are given. This is the form to reach for: styled(design, layer="pen1").

None
fill str | None

Individual fields, applied over style where both are given. This is the form to reach for: styled(design, layer="pen1").

None

Returns:

Type Description
Design

The same geometry, with styles merged over whatever it already carried -- so a second call that names a color keeps the layer the first one set.

Source code in src/geomotif/core/style.py
def styled(
    design: Design,
    style: Style | None = None,
    *,
    layer: str | None = None,
    stroke: str | None = None,
    width: float | None = None,
    fill: str | None = None,
) -> Design:
    """Return ``design`` with a style laid over every stroke and loose point.

    Parameters
    ----------
    design : Design
        What to style. Returned unchanged if nothing is actually stated.
    style : Style, optional
        A whole style to apply.
    layer, stroke, width, fill
        Individual fields, applied over ``style`` where both are given. This
        is the form to reach for: ``styled(design, layer="pen1")``.

    Returns
    -------
    Design
        The same geometry, with styles merged over whatever it already
        carried -- so a second call that names a color keeps the layer the
        first one set.
    """
    over = Style(layer=layer, stroke=stroke, width=width, fill=fill)
    applied = (style or Style()).merged(over)
    if not applied:
        return design

    meta = dict(design.meta)
    meta[PATH_STYLE_KEY] = tuple(
        (existing or Style()).merged(applied) for existing in styles_of(design)
    )
    meta[POINT_STYLE_KEY] = tuple(
        (existing or Style()).merged(applied) for existing in point_styles_of(design)
    )
    return Design(design.paths, design.points, MappingProxyType(meta))

styles_of

styles_of(design: Design) -> tuple[Style | None, ...]

Return one style per stroke, None where a stroke has none.

Always exactly as long as design.paths, whatever the metadata says, so callers can zip the two without checking.

Source code in src/geomotif/core/style.py
def styles_of(design: Design) -> tuple[Style | None, ...]:
    """Return one style per stroke, ``None`` where a stroke has none.

    Always exactly as long as ``design.paths``, whatever the metadata says, so
    callers can zip the two without checking.
    """
    return _aligned(design.meta, PATH_STYLE_KEY, len(design.paths))

clip_to

clip_to(design: Design, bounds: Bounds) -> Design

Trim design to a rectangle, splitting paths that leave and re-enter.

Clipped paths come back open even if they went in closed: a shape whose outline has been cut is no longer a closed loop, and pretending otherwise would draw a chord across the gap. Loose points outside the rectangle are dropped.

Source code in src/geomotif/core/transform.py
def clip_to(design: Design, bounds: Bounds) -> Design:
    """Trim ``design`` to a rectangle, splitting paths that leave and re-enter.

    Clipped paths come back open even if they went in closed: a shape whose
    outline has been cut is no longer a closed loop, and pretending otherwise
    would draw a chord across the gap. Loose points outside the rectangle are
    dropped.
    """
    paths: list[Path] = []
    # One stroke in can be several strokes out, or none; ``sources`` records
    # which one each fragment came from so its style follows it across.
    sources: list[int] = []

    def emit(run: list[Point], source: int) -> None:
        """Keep a fragment, if it is long enough to draw."""
        if len(run) > 1:
            paths.append(Path(tuple(run)))
            sources.append(source)

    for index, path in enumerate(design.paths):
        vertices = list(path.points)
        if path.closed and len(vertices) > 2:
            vertices.append(vertices[0])
        run: list[Point] = []
        for a, b in itertools.pairwise(vertices):
            clipped = _clip_segment(a, b, bounds)
            if clipped is None:
                emit(run, index)
                run = []
                continue
            start, end = clipped
            if not run:
                run.append(start)
            elif math.dist(run[-1], start) > 1e-12:
                # The path left the box and came back: start a new stroke
                # rather than drawing the shortcut across the outside.
                emit(run, index)
                run = [start]
            run.append(end)
        emit(run, index)
    kept = [i for i, p in enumerate(design.points) if p in bounds]
    return Design(
        tuple(paths),
        tuple(design.points[i] for i in kept),
        select_styles(design.meta, paths=sources, points=kept),
    )

fit_to

fit_to(design: Design, width: float, height: float, *, padding: float = 0.0, flip_y: bool = False) -> Design

Scale and center design inside a canvas. See :meth:Design.fit.

Source code in src/geomotif/core/transform.py
def fit_to(
    design: Design,
    width: float,
    height: float,
    *,
    padding: float = 0.0,
    flip_y: bool = False,
) -> Design:
    """Scale and center ``design`` inside a canvas. See :meth:`Design.fit`."""
    return design.fit(width, height, padding=padding, flip_y=flip_y)

jitter

jitter(design: Design, amount: float, *, seed: int | None = None) -> Design

Randomly displace every point, for controlled hand-drawn irregularity.

Each coordinate is offset independently by a uniform value in [-amount, amount]. The RNG is private to this call -- the global :mod:random state is never touched -- so a given seed always reproduces the same result no matter what else the program is doing.

Source code in src/geomotif/core/transform.py
def jitter(design: Design, amount: float, *, seed: int | None = None) -> Design:
    """Randomly displace every point, for controlled hand-drawn irregularity.

    Each coordinate is offset independently by a uniform value in
    ``[-amount, amount]``. The RNG is private to this call -- the global
    :mod:`random` state is never touched -- so a given ``seed`` always
    reproduces the same result no matter what else the program is doing.
    """
    if amount < 0:
        raise ValueError(f"amount must be >= 0, got {amount}")
    rng = random.Random(seed)

    def shift(p: Point) -> Point:
        return (p[0] + rng.uniform(-amount, amount), p[1] + rng.uniform(-amount, amount))

    paths = tuple(
        replace(path, points=tuple(shift(p) for p in path.points)) for path in design.paths
    )
    return Design(paths, tuple(shift(p) for p in design.points), design.meta)

layer

layer(*designs: Design) -> Design

Overlay designs into one. Equivalent to repeated +.

Source code in src/geomotif/core/transform.py
def layer(*designs: Design) -> Design:
    """Overlay designs into one. Equivalent to repeated ``+``."""
    result = Design()
    for design in designs:
        result = result + design
    return result

mirror_axis

mirror_axis(design: Design, angle: float = 0.0, *, through: Point = (0.0, 0.0)) -> Design

Return design overlaid with its reflection across a line.

Source code in src/geomotif/core/transform.py
def mirror_axis(design: Design, angle: float = 0.0, *, through: Point = (0.0, 0.0)) -> Design:
    """Return ``design`` overlaid with its reflection across a line."""
    return design + design.transformed(Affine.mirror(angle, through=through))

offset_path

offset_path(path: Path, distance: float) -> Path

Return a parallel copy of path, offset by distance to its left.

Left is relative to the direction of travel in y-up coordinates, so a negative distance offsets to the right. Corners are mitered, with a limit that falls back to a plain bevel on very sharp turns.

This is the "simple parallel stroke" of guilloché and knot outlines, not a CAD offset: self-intersections on tight concave corners are not cleaned up, and the result may cross itself where the offset exceeds the local radius of curvature.

Source code in src/geomotif/core/transform.py
def offset_path(path: Path, distance: float) -> Path:
    """Return a parallel copy of ``path``, offset by ``distance`` to its left.

    Left is relative to the direction of travel in y-up coordinates, so a
    negative distance offsets to the right. Corners are mitered, with a limit
    that falls back to a plain bevel on very sharp turns.

    This is the "simple parallel stroke" of guilloché and knot outlines, not a
    CAD offset: self-intersections on tight concave corners are not cleaned
    up, and the result may cross itself where the offset exceeds the local
    radius of curvature.
    """
    vertices = list(path.points)
    if len(vertices) < 2:
        return path
    if path.closed and len(vertices) > 2:
        vertices.append(vertices[0])

    normals: list[Point] = []
    for a, b in itertools.pairwise(vertices):
        dx, dy = b[0] - a[0], b[1] - a[1]
        length = math.hypot(dx, dy)
        # Zero-length segments have no direction; inherit the previous one so
        # a duplicated vertex does not punch a hole in the offset.
        if length == 0.0:
            normals.append(normals[-1] if normals else (0.0, 0.0))
        else:
            normals.append((-dy / length, dx / length))

    # For unit normals n1, n2 the miter vector is 2*(n1 + n2) / |n1 + n2|^2:
    # it bisects the corner and is exactly 1/cos(half-angle) long, which is
    # what keeps the offset stroke a constant distance from the original.
    miter_limit = 4.0
    bevel_below = 2.0 / miter_limit
    offset: list[Point] = []
    for i, vertex in enumerate(vertices):
        before = normals[i - 1] if i > 0 else normals[0]
        after = normals[i] if i < len(normals) else normals[-1]
        mx, my = before[0] + after[0], before[1] + after[1]
        scale = math.hypot(mx, my)
        if scale < bevel_below:
            # Near-reversal: the miter would shoot off toward infinity, so
            # fall back to the outgoing normal and let the corner bevel.
            nx, ny = after
        else:
            nx, ny = 2.0 * mx / (scale * scale), 2.0 * my / (scale * scale)
        offset.append((vertex[0] + nx * distance, vertex[1] + ny * distance))

    if path.closed and len(path.points) > 2:
        offset.pop()
    return replace(path, points=tuple(offset))

radial_repeat

radial_repeat(design: Design, n: int, *, about: Point = (0.0, 0.0), mirror: bool = False) -> Design

Repeat design n times evenly around a point -- the mandala workhorse.

Parameters:

Name Type Description Default
design Design

The unit to repeat.

required
n int

Number of copies, including the original. Must be >= 1.

required
about (float, float)

Center of rotation.

(0.0, 0.0)
mirror bool

Also emit a reflected copy in each sector, giving dihedral rather than merely cyclic symmetry.

False
Source code in src/geomotif/core/transform.py
def radial_repeat(
    design: Design,
    n: int,
    *,
    about: Point = (0.0, 0.0),
    mirror: bool = False,
) -> Design:
    """Repeat ``design`` ``n`` times evenly around a point -- the mandala workhorse.

    Parameters
    ----------
    design : Design
        The unit to repeat.
    n : int
        Number of copies, including the original. Must be >= 1.
    about : (float, float), optional
        Center of rotation.
    mirror : bool, optional
        Also emit a reflected copy in each sector, giving dihedral rather
        than merely cyclic symmetry.
    """
    if n < 1:
        raise ValueError(f"n must be >= 1, got {n}")
    step = math.tau / n
    parts: list[Design] = []
    for i in range(n):
        rotation = Affine.rotate(i * step, about=about)
        parts.append(design.transformed(rotation))
        if mirror:
            # Reflect in the sector's own bisector so the pair meets cleanly
            # at the spoke rather than overlapping the neighbouring copy.
            parts.append(design.transformed(rotation @ Affine.mirror(step / 2.0, through=about)))
    return layer(*parts)

snap

snap(design: Design, step: float = 1.0, *, mode: SnapMode = 'half-even', drop_duplicates: bool = True) -> Design

Move every point onto the nearest line of a square grid.

This is rounding applied to the design rather than to each file as it is written, which is the difference that matters: every exporter then agrees, and a plot of the result shows what the file will actually contain. design.snapped() alone rounds to whole units.

Snapping trades this library's exact arc-length spacing for grid alignment. Points that were an equal real distance apart come out equal only to within half a step, so snap after resampling and choose a step well below the spacing if the evenness is what you are there for.

Parameters:

Name Type Description Default
design Design

What to snap.

required
step float

Grid size, in the design's own units. Must be finite and positive. 0.5 snaps to half units and 5 to a five-unit lattice -- any grid at all, where precision= can only reach the powers of ten.

1.0
mode ('half-even', 'half-up', 'floor', 'ceil', 'trunc')

How a coordinate between two grid lines is resolved. half-even is the default and matches the writers' precision=: it goes to the nearer line, and a coordinate exactly halfway goes to the even one. half-up also goes to the nearer line but sends a halfway coordinate away from zero, which is the rounding most people were taught. floor, ceil and trunc always go the same way -- down, up, and toward zero -- which is what a one-sided tolerance asks for.

"half-even"
drop_duplicates bool

Remove points that a coarse grid has landed on top of their immediate neighbour, and then any stroke left with fewer than two points. On by default, because those are zero-length segments: ink a plotter cannot draw and a pen-down/pen-up it should not spend the time on. Turn it off to keep the point count exactly as it was, which is what a caller feeding a fixed-size buffer or a per-point parallel array needs.

True

Returns:

Type Description
Design

Snapped, with each surviving stroke's style following it across.

Raises:

Type Description
ValueError

If step is not finite and positive, or mode is not one of the five above.

Examples:

>>> from geomotif import Design, Path
>>> square = Design((Path(((0.4, 0.4), (9.6, 0.4), (9.6, 9.6))),))
>>> list(snap(square))
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
>>> list(snap(square, 0.25))
[(0.5, 0.5), (9.5, 0.5), (9.5, 9.5)]
Source code in src/geomotif/core/transform.py
def snap(
    design: Design,
    step: float = 1.0,
    *,
    mode: SnapMode = "half-even",
    drop_duplicates: bool = True,
) -> Design:
    """Move every point onto the nearest line of a square grid.

    This is rounding applied to the *design* rather than to each file as it is
    written, which is the difference that matters: every exporter then agrees,
    and a plot of the result shows what the file will actually contain.
    ``design.snapped()`` alone rounds to whole units.

    Snapping trades this library's exact arc-length spacing for grid alignment.
    Points that were an equal real distance apart come out equal only to within
    half a step, so snap *after* resampling and choose a step well below the
    spacing if the evenness is what you are there for.

    Parameters
    ----------
    design : Design
        What to snap.
    step : float, optional
        Grid size, in the design's own units. Must be finite and positive.
        ``0.5`` snaps to half units and ``5`` to a five-unit lattice -- any
        grid at all, where ``precision=`` can only reach the powers of ten.
    mode : {"half-even", "half-up", "floor", "ceil", "trunc"}, optional
        How a coordinate between two grid lines is resolved. ``half-even`` is
        the default and matches the writers' ``precision=``: it goes to the
        nearer line, and a coordinate exactly halfway goes to the even one.
        ``half-up`` also goes to the nearer line but sends a halfway coordinate
        away from zero, which is the rounding most people were taught.
        ``floor``, ``ceil`` and ``trunc`` always go the same way -- down, up,
        and toward zero -- which is what a one-sided tolerance asks for.
    drop_duplicates : bool, optional
        Remove points that a coarse grid has landed on top of their immediate
        neighbour, and then any stroke left with fewer than two points. On by
        default, because those are zero-length segments: ink a plotter cannot
        draw and a pen-down/pen-up it should not spend the time on. Turn it off
        to keep the point count exactly as it was, which is what a caller
        feeding a fixed-size buffer or a per-point parallel array needs.

    Returns
    -------
    Design
        Snapped, with each surviving stroke's style following it across.

    Raises
    ------
    ValueError
        If ``step`` is not finite and positive, or ``mode`` is not one of the
        five above.

    Examples
    --------
    >>> from geomotif import Design, Path
    >>> square = Design((Path(((0.4, 0.4), (9.6, 0.4), (9.6, 9.6))),))
    >>> list(snap(square))
    [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
    >>> list(snap(square, 0.25))
    [(0.5, 0.5), (9.5, 0.5), (9.5, 9.5)]
    """
    if not math.isfinite(step) or step <= 0.0:
        raise ValueError(f"step must be finite and > 0, got {step}")
    to_grid = _quantizer(mode)
    places = _grid_places(step)

    def place(p: Point) -> Point:
        x, y = p
        return (round(to_grid(x / step) * step, places), round(to_grid(y / step) * step, places))

    paths: list[Path] = []
    # Which source stroke each surviving one came from, so the styles can be
    # carried across the ones that collapsed away.
    sources: list[int] = []
    for index, path in enumerate(design.paths):
        moved = tuple(place(p) for p in path.points)
        if drop_duplicates:
            moved = _without_repeats(moved, closed=path.closed)
            if len(moved) < 2:
                continue  # the whole stroke landed on one grid point
        paths.append(replace(path, points=moved))
        sources.append(index)

    loose = tuple(place(p) for p in design.points)
    kept = list(range(len(loose)))
    if drop_duplicates:
        # Every duplicate, not merely a consecutive one. A stroke's points are
        # a walk, so only its neighbours can be redundant -- a later revisit is
        # a crossing. Loose points are a set with no walk through them, and
        # which two of them happen to be adjacent in the tuple says nothing
        # about the drawing, so dropping by position would be arbitrary.
        seen: set[Point] = set()
        kept = []
        for i, p in enumerate(loose):
            if p not in seen:
                seen.add(p)
                kept.append(i)
        loose = tuple(loose[i] for i in kept)

    return Design(tuple(paths), loose, select_styles(design.meta, paths=sources, points=kept))

symmetry_group

symmetry_group(design: Design, group: str) -> Design

Apply a full cyclic Cn or dihedral Dn symmetry group.

Parameters:

Name Type Description Default
design Design

The fundamental domain to replicate.

required
group str

"C6" for 6-fold rotation, "D6" for 6-fold rotation plus mirrors. Case-insensitive.

required
Source code in src/geomotif/core/transform.py
def symmetry_group(design: Design, group: str) -> Design:
    """Apply a full cyclic ``Cn`` or dihedral ``Dn`` symmetry group.

    Parameters
    ----------
    design : Design
        The fundamental domain to replicate.
    group : str
        ``"C6"`` for 6-fold rotation, ``"D6"`` for 6-fold rotation plus
        mirrors. Case-insensitive.
    """
    name = group.strip().upper()
    kind, order = name[:1], name[1:]
    if kind not in ("C", "D") or not order.isdigit():
        raise ValueError(f"group must look like 'C6' or 'D6', got {group!r}")
    return radial_repeat(design, int(order), mirror=kind == "D")

tile

tile(design: Design, cols: int, rows: int, *, dx: float, dy: float, stagger: float = 0.0) -> Design

Repeat design on a rectangular lattice.

Parameters:

Name Type Description Default
design Design

The unit cell contents.

required
cols int

Lattice size. Both must be >= 1.

required
rows int

Lattice size. Both must be >= 1.

required
dx float

Spacing between columns and rows.

required
dy float

Spacing between columns and rows.

required
stagger float

Fraction of dx by which to offset every other row -- 0.5 gives the familiar brick/hexagonal offset.

0.0
Source code in src/geomotif/core/transform.py
def tile(
    design: Design,
    cols: int,
    rows: int,
    *,
    dx: float,
    dy: float,
    stagger: float = 0.0,
) -> Design:
    """Repeat ``design`` on a rectangular lattice.

    Parameters
    ----------
    design : Design
        The unit cell contents.
    cols, rows : int
        Lattice size. Both must be >= 1.
    dx, dy : float
        Spacing between columns and rows.
    stagger : float, optional
        Fraction of ``dx`` by which to offset every other row -- ``0.5``
        gives the familiar brick/hexagonal offset.
    """
    if cols < 1 or rows < 1:
        raise ValueError(f"cols and rows must be >= 1, got {cols}x{rows}")
    parts = [
        design.transformed(Affine.translate(col * dx + (row % 2) * stagger * dx, row * dy))
        for row in range(rows)
        for col in range(cols)
    ]
    return layer(*parts)

from_spec

from_spec(data: Mapping[str, object]) -> Motif

Rebuild the motif a spec describes.

The version stamp is not consulted; a spec is data, and data that loaded once should keep loading.

Raises:

Type Description
ValueError

If the mapping names no motif, or names a value type this library will not import.

KeyError

If the motif name is not registered -- including when it belongs to a plugin that is not installed.

Source code in src/geomotif/io/spec.py
def from_spec(data: Mapping[str, object]) -> Motif:
    """Rebuild the motif a spec describes.

    The version stamp is not consulted; a spec is data, and data that loaded
    once should keep loading.

    Raises
    ------
    ValueError
        If the mapping names no motif, or names a value type this library will
        not import.
    KeyError
        If the motif name is not registered -- including when it belongs to a
        plugin that is not installed.
    """
    return _decode_spec(data, _importable_packages(), where="spec")

load_design

load_design(path: str | PathLike[str]) -> Design

Read a design back from a JSON file written by :func:save_design.

A plain JSON array of pairs -- what :func:save_points writes -- also loads, as a design of loose points with no strokes. The two shapes are an array and an object, so there is nothing to guess at.

Returns:

Type Description
Design

With meta restored where the file recorded it. The metadata is decoded but the motif is not rebuilt, so a design saved by a plugin still loads on a machine that does not have that plugin installed.

Raises:

Type Description
ValueError

If the file is not one of the two shapes above.

Source code in src/geomotif/io/points.py
def load_design(path: str | PathLike[str]) -> Design:
    """Read a design back from a JSON file written by :func:`save_design`.

    A plain JSON array of pairs -- what :func:`save_points` writes -- also
    loads, as a design of loose points with no strokes. The two shapes are an
    array and an object, so there is nothing to guess at.

    Returns
    -------
    Design
        With ``meta`` restored where the file recorded it. The metadata is
        decoded but the motif is *not* rebuilt, so a design saved by a plugin
        still loads on a machine that does not have that plugin installed.

    Raises
    ------
    ValueError
        If the file is not one of the two shapes above.
    """
    data = json.loads(pathlib.Path(path).read_text())
    match data:
        case list():
            return Design(points=_points(data, where="the file"))
        case {"paths": _} | {"points": _}:
            strokes = enumerate(data.get("paths", []))
            return Design(
                paths=tuple(_path(entry, index) for index, entry in strokes),
                points=_points(data.get("points", []), where="points"),
                meta=(
                    _meta_from_spec(data["meta"])
                    if isinstance(data.get("meta"), dict)
                    else EMPTY_META
                ),
            )
        case _:
            raise ValueError(
                f"{path} is not a design file: expected a JSON array of [x, y] pairs, or "
                f"an object with 'paths' and 'points' keys, got {type(data).__name__}"
            )

load_spec

load_spec(path: str | PathLike[str]) -> Motif

Read a spec file and return the motif it describes.

Returns:

Type Description
Motif

Ready to :meth:~geomotif.Motif.build or :meth:~geomotif.Motif.generate.

Source code in src/geomotif/io/spec.py
def load_spec(path: str | PathLike[str]) -> Motif:
    """Read a spec file and return the motif it describes.

    Returns
    -------
    Motif
        Ready to :meth:`~geomotif.Motif.build` or
        :meth:`~geomotif.Motif.generate`.
    """
    data = json.loads(pathlib.Path(path).read_text())
    if not isinstance(data, dict):
        raise ValueError(
            f"{path} is not a spec file: expected a JSON object, got {type(data).__name__}"
        )
    return from_spec(data)

save_design

save_design(design: Design, path: str | PathLike[str], *, fmt: PointFormat | None = None, precision: int | None = None, meta: bool = True) -> Path

Write a design, strokes kept apart, and return the path written.

Parameters:

Name Type Description Default
design Design

What to write.

required
path str or path - like

Destination file.

required
fmt ('csv', 'txt', 'json')

Output format, inferred from the suffix when omitted.

  • csv -- a path,x,y header, then one row per point carrying the index of the stroke it belongs to. A design's loose points belong to no stroke, so their path cell is left empty.
  • txt -- one tab-separated x<TAB>y line per point, with a blank line between strokes: the convention gnuplot and most plotter toolchains already understand as "lift the pen here".
  • json -- the structured form, and the only one :func:load_design reads back.
"csv"
precision int

Round coordinates to this many decimal places, as for :func:save_points.

None
meta bool

Record the design's recipe alongside its points, for the JSON format only. Turn it off for a design whose motif takes a parameter that cannot be written as data.

True

Returns:

Type Description
Path

The file that was written.

Raises:

Type Description
TypeError

If meta is requested and a parameter is not JSON data. The message names the parameter; passing meta=False writes the points anyway.

Source code in src/geomotif/io/points.py
def save_design(
    design: Design,
    path: str | PathLike[str],
    *,
    fmt: PointFormat | None = None,
    precision: int | None = None,
    meta: bool = True,
) -> pathlib.Path:
    """Write a design, strokes kept apart, and return the path written.

    Parameters
    ----------
    design : Design
        What to write.
    path : str or path-like
        Destination file.
    fmt : {"csv", "txt", "json"}, optional
        Output format, inferred from the suffix when omitted.

        * ``csv``  -- a ``path,x,y`` header, then one row per point carrying
          the index of the stroke it belongs to. A design's loose points
          belong to no stroke, so their ``path`` cell is left empty.
        * ``txt``  -- one tab-separated ``x<TAB>y`` line per point, with a
          blank line between strokes: the convention gnuplot and most plotter
          toolchains already understand as "lift the pen here".
        * ``json`` -- the structured form, and the only one
          :func:`load_design` reads back.
    precision : int, optional
        Round coordinates to this many decimal places, as for
        :func:`save_points`.
    meta : bool, optional
        Record the design's recipe alongside its points, for the JSON format
        only. Turn it off for a design whose motif takes a parameter that
        cannot be written as data.

    Returns
    -------
    pathlib.Path
        The file that was written.

    Raises
    ------
    TypeError
        If ``meta`` is requested and a parameter is not JSON data. The message
        names the parameter; passing ``meta=False`` writes the points anyway.
    """
    target = pathlib.Path(path)
    chosen = _format_for(target, fmt)
    round_to = _rounder(precision)

    def pairs(points: Iterable[Point]) -> list[list[float | int]]:
        return [[round_to(x), round_to(y)] for x, y in points]

    match chosen:
        case "csv":
            with target.open("w", newline="") as f:
                writer = csv.writer(f)
                writer.writerow(("path", "x", "y"))
                for index, stroke in enumerate(design.paths):
                    writer.writerows((index, x, y) for x, y in pairs(stroke.points))
                writer.writerows(("", x, y) for x, y in pairs(design.points))
        case "txt":
            blocks = [pairs(stroke.points) for stroke in design.paths]
            if design.points:
                blocks.append(pairs(design.points))
            target.write_text(
                "\n".join("".join(f"{x}\t{y}\n" for x, y in block) for block in blocks)
            )
        case "json":
            # Imported at call time, not module scope: this module is part of
            # the package whose version it stamps, so the two would cycle.
            from .. import __version__

            blob: dict[str, object] = {
                VERSION_KEY: __version__,
                "paths": [
                    {"points": pairs(stroke.points), "closed": stroke.closed}
                    for stroke in design.paths
                ],
                "points": pairs(design.points),
            }
            if meta and design.meta:
                # The file already stamps its own version; a second copy inside
                # the recipe would only give the two a chance to disagree.
                blob["meta"] = {k: v for k, v in to_spec(design).items() if k != VERSION_KEY}
            target.write_text(json.dumps(blob) + "\n")
    return target

save_dxf

save_dxf(design: Design, path: str | PathLike[str], *, layer: str = '0', precision: int = 4) -> Path

Write a design to a DXF file and return the path written.

See :func:to_dxf for what the options mean.

Source code in src/geomotif/io/dxf.py
def save_dxf(
    design: Design,
    path: str | PathLike[str],
    *,
    layer: str = "0",
    precision: int = 4,
) -> pathlib.Path:
    """Write a design to a DXF file and return the path written.

    See :func:`to_dxf` for what the options mean.
    """
    target = pathlib.Path(path)
    target.write_text(to_dxf(design, layer=layer, precision=precision))
    return target

save_gif

save_gif(frames: Sequence[Design], path: str | PathLike[str], **kwargs: Any) -> Path

Write an animated GIF and return the path written.

Keyword arguments are passed straight through to :func:to_gif.

Source code in src/geomotif/io/gif.py
def save_gif(frames: Sequence[Design], path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write an animated GIF and return the path written.

    Keyword arguments are passed straight through to :func:`to_gif`.
    """
    target = pathlib.Path(path)
    target.write_bytes(to_gif(frames, **kwargs))
    return target

save_jpeg

save_jpeg(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> Path

Render a design -- or re-encode a raster -- as JPEG and write it.

Keyword arguments are passed straight through to :func:to_jpeg.

Source code in src/geomotif/io/jpeg.py
def save_jpeg(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Render a design -- or re-encode a raster -- as JPEG and write it.

    Keyword arguments are passed straight through to :func:`to_jpeg`.
    """
    target = pathlib.Path(path)
    target.write_bytes(to_jpeg(source, **kwargs))
    return target

save_plotter_svg

save_plotter_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> Path

Write a plotter-ready SVG and return the path written.

Keyword arguments are passed straight through to :func:to_plotter_svg.

Source code in src/geomotif/io/plotter.py
def save_plotter_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write a plotter-ready SVG and return the path written.

    Keyword arguments are passed straight through to :func:`to_plotter_svg`.
    """
    target = pathlib.Path(path)
    target.write_text(to_plotter_svg(design, **kwargs))
    return target

save_png

save_png(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> Path

Render a design -- or re-encode a raster -- as a PNG and write it.

Keyword arguments are passed straight through to :func:to_png.

Source code in src/geomotif/io/png.py
def save_png(source: Design | Raster, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Render a design -- or re-encode a raster -- as a PNG and write it.

    Keyword arguments are passed straight through to :func:`to_png`.
    """
    target = pathlib.Path(path)
    target.write_bytes(to_png(source, **kwargs))
    return target

save_points

save_points(points: Iterable[Point], path: str | PathLike[str], *, fmt: PointFormat | None = None, precision: int | None = None) -> Path

Write points to a file and return the path written.

Parameters:

Name Type Description Default
points iterable of (float, float)

The points to export. A :class:~geomotif.Design is itself an iterable of points, so it can be passed directly.

required
path str or path - like

Destination file.

required
fmt ('csv', 'txt', 'json')

Output format. Inferred from the file suffix when omitted (.csv, .txt/.tsv, .json).

  • csv -- an x,y header followed by one x,y row per point
  • txt -- one tab-separated x<TAB>y line per point, no header
  • json -- a JSON array of [x, y] pairs
"csv"
precision int

Round coordinates to this many decimal places. 0 and below write whole integers, each further step back rounding to tens, hundreds and so on. Default keeps full float precision.

This rounds the file rather than the design, so it says nothing about what the other writers do with the same points. :meth:~geomotif.Design.snapped rounds the geometry itself -- onto any grid, not only powers of ten -- and every writer then agrees.

None

Returns:

Type Description
Path

The file that was written.

Source code in src/geomotif/io/points.py
def save_points(
    points: Iterable[Point],
    path: str | PathLike[str],
    *,
    fmt: PointFormat | None = None,
    precision: int | None = None,
) -> pathlib.Path:
    """Write points to a file and return the path written.

    Parameters
    ----------
    points : iterable of (float, float)
        The points to export. A :class:`~geomotif.Design` is itself an
        iterable of points, so it can be passed directly.
    path : str or path-like
        Destination file.
    fmt : {"csv", "txt", "json"}, optional
        Output format. Inferred from the file suffix when omitted
        (``.csv``, ``.txt``/``.tsv``, ``.json``).

        * ``csv``  -- an ``x,y`` header followed by one ``x,y`` row per point
        * ``txt``  -- one tab-separated ``x<TAB>y`` line per point, no header
        * ``json`` -- a JSON array of ``[x, y]`` pairs
    precision : int, optional
        Round coordinates to this many decimal places. ``0`` and below write
        whole integers, each further step back rounding to tens, hundreds and
        so on. Default keeps full float precision.

        This rounds the *file* rather than the design, so it says nothing about
        what the other writers do with the same points.
        :meth:`~geomotif.Design.snapped` rounds the geometry itself -- onto any
        grid, not only powers of ten -- and every writer then agrees.

    Returns
    -------
    pathlib.Path
        The file that was written.
    """
    target = pathlib.Path(path)
    chosen = _format_for(target, fmt)
    round_to = _rounder(precision)
    rows = [(round_to(x), round_to(y)) for x, y in points]

    match chosen:
        case "csv":
            with target.open("w", newline="") as f:
                writer = csv.writer(f)
                writer.writerow(("x", "y"))
                writer.writerows(rows)
        case "txt":
            target.write_text("".join(f"{x}\t{y}\n" for x, y in rows))
        case "json":
            target.write_text(json.dumps([[x, y] for x, y in rows]) + "\n")
    return target

save_spec

save_spec(source: SupportsBuild | Design, path: str | PathLike[str], *, indent: int | None = 2) -> Path

Write a motif's recipe to a JSON file and return the path written.

Parameters:

Name Type Description Default
source Motif or Design

What to describe; see :func:to_spec.

required
path str or path - like

Destination file.

required
indent int

Passed through to :func:json.dumps. Indented by default: a spec is a few hundred bytes and is meant to be opened and edited by hand.

2

Returns:

Type Description
Path

The file that was written.

Source code in src/geomotif/io/spec.py
def save_spec(
    source: SupportsBuild | Design,
    path: str | PathLike[str],
    *,
    indent: int | None = 2,
) -> pathlib.Path:
    """Write a motif's recipe to a JSON file and return the path written.

    Parameters
    ----------
    source : Motif or Design
        What to describe; see :func:`to_spec`.
    path : str or path-like
        Destination file.
    indent : int, optional
        Passed through to :func:`json.dumps`. Indented by default: a spec is a
        few hundred bytes and is meant to be opened and edited by hand.

    Returns
    -------
    pathlib.Path
        The file that was written.
    """
    target = pathlib.Path(path)
    target.write_text(json.dumps(to_spec(source), indent=indent) + "\n")
    return target

save_svg

save_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> Path

Write a design to an SVG file and return the path written.

Keyword arguments are passed straight through to :func:to_svg.

Source code in src/geomotif/io/svg.py
def save_svg(design: Design, path: str | PathLike[str], **kwargs: Any) -> pathlib.Path:
    """Write a design to an SVG file and return the path written.

    Keyword arguments are passed straight through to :func:`to_svg`.
    """
    target = pathlib.Path(path)
    target.write_text(to_svg(design, **kwargs))
    return target

to_dxf

to_dxf(design: Design, *, layer: str = '0', precision: int = 4) -> str

Render a design as a DXF R12 document.

Parameters:

Name Type Description Default
design Design

What to write. Strokes become POLYLINE entities -- closed ones carry the closed flag rather than a repeated final vertex -- and loose points become POINT entities.

required
layer str

Layer for geometry that does not name one of its own. "0" is the layer every DXF file already has; any other name is declared in the file's layer table, so the result is valid on its own rather than relying on the reader to invent the layer.

'0'
precision int

Decimal places for coordinates.

4

Returns:

Type Description
str

A complete DXF R12 document.

Raises:

Type Description
ValueError

If the design is empty, the precision is negative, or a layer name -- the argument's or a style's -- is not one R12 permits.

Source code in src/geomotif/io/dxf.py
def to_dxf(design: Design, *, layer: str = "0", precision: int = 4) -> str:
    """Render a design as a DXF R12 document.

    Parameters
    ----------
    design : Design
        What to write. Strokes become ``POLYLINE`` entities -- closed ones
        carry the closed flag rather than a repeated final vertex -- and loose
        points become ``POINT`` entities.
    layer : str, optional
        Layer for geometry that does not name one of its own. ``"0"`` is the
        layer every DXF file already has; any other name is declared in the
        file's layer table, so the result is valid on its own rather than
        relying on the reader to invent the layer.
    precision : int, optional
        Decimal places for coordinates.

    Returns
    -------
    str
        A complete DXF R12 document.

    Raises
    ------
    ValueError
        If the design is empty, the precision is negative, or a layer name --
        the argument's or a style's -- is not one R12 permits.
    """
    if not len(design):
        raise ValueError("cannot write an empty design to DXF: there is nothing to draw")
    if precision < 0:
        raise ValueError(f"precision must be >= 0, got {precision}")

    used = _layers_used(design, layer)
    bounds = design.bounds

    def num(value: float) -> str:
        return f"{value:.{precision}f}"

    parts = [
        *_section(
            "HEADER",
            _tag(9, "$ACADVER"),
            _tag(1, "AC1009"),
            # The drawing extents: what "zoom to fit" uses when the file opens.
            _tag(9, "$EXTMIN"),
            _point(bounds.min_x, bounds.min_y, num),
            _tag(9, "$EXTMAX"),
            _point(bounds.max_x, bounds.max_y, num),
        ),
        *_section("TABLES", *_layer_table(used)),
        *_section("ENTITIES", *_entities(design, layer, num)),
        _tag(0, "EOF"),
    ]
    return "".join(parts)

to_gif

to_gif(frames: Sequence[Design], *, width: int = 480, height: int = 480, padding: float = 8.0, fps: float = 20.0, loop: int = 0, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, antialias: bool = False, aa_level: int = 8, dither: bool = True, transparent: bool = False) -> bytes

Render a sequence of designs as an animated GIF.

Every frame is drawn against the same world rectangle -- the union of all of their bounds -- and the same color table, so a drawing that grows stays put instead of swimming about as its own extent changes.

Parameters:

Name Type Description Default
frames sequence of Design

What to draw, in order. At least one is needed.

required
width int

Canvas size in pixels.

480
height int

Canvas size in pixels.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
fps float

Frames per second. GIF stores a delay in hundredths of a second, so the rate is rounded to what the format can actually say.

20.0
loop int

How many times to play; 0 means forever, which is what everyone expects of a GIF, and is the default. 1 plays it once and stops.

0
ink str

Default stroke color and the color behind everything. A stroke with a style of its own is drawn in that instead.

'#0b0b0b'
background str

Default stroke color and the color behind everything. A stroke with a style of its own is drawn in that instead.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
antialias bool

Supersample and blend edges. Off by default, so the output is exactly the hard-edged picture it has always been.

False
aa_level int

When antialiasing, how many shades an edge may blend into per color pair, which is what keeps the whole animation inside GIF's 256-color budget. Must be >= 1.

8
dither bool

Error-diffuse the round-off onto a gradient, which keeps an antialiased edge on a colored ground from banding inside the palette budget. On by default.

True
transparent bool

Leave the background empty. Index 0 is flagged transparent in the file, so the drawing sits over whatever the page shows instead of a painted ground. Off by default, so the picture stays what it has always been.

False

Returns:

Type Description
bytes

A complete GIF89a file.

Raises:

Type Description
ValueError

If there are no frames, the rate is not positive, the antialias level is zero, or the designs' own colors (the inks and background) exceed 256 between them -- which is GIF's limit, not this writer's.

Source code in src/geomotif/io/gif.py
def to_gif(
    frames: Sequence[Design],
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    fps: float = 20.0,
    loop: int = 0,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    antialias: bool = False,
    aa_level: int = 8,
    dither: bool = True,
    transparent: bool = False,
) -> bytes:
    """Render a sequence of designs as an animated GIF.

    Every frame is drawn against the **same** world rectangle -- the union of
    all of their bounds -- and the same color table, so a drawing that grows
    stays put instead of swimming about as its own extent changes.

    Parameters
    ----------
    frames : sequence of Design
        What to draw, in order. At least one is needed.
    width, height : int
        Canvas size in pixels.
    padding : float
        Margin reserved on all four sides, in pixels.
    fps : float
        Frames per second. GIF stores a delay in hundredths of a second, so
        the rate is rounded to what the format can actually say.
    loop : int
        How many times to play; ``0`` means forever, which is what everyone
        expects of a GIF, and is the default. ``1`` plays it once and stops.
    ink, background : str
        Default stroke color and the color behind everything. A stroke with
        a style of its own is drawn in that instead.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    antialias : bool
        Supersample and blend edges. Off by default, so the output is exactly
        the hard-edged picture it has always been.
    aa_level : int
        When antialiasing, how many shades an edge may blend into per color
        pair, which is what keeps the whole animation inside GIF's 256-color
        budget. Must be >= 1.
    dither : bool
        Error-diffuse the round-off onto a gradient, which keeps an
        antialiased edge on a colored ground from banding inside the palette
        budget. On by default.
    transparent : bool
        Leave the background empty. Index 0 is flagged transparent in the
        file, so the drawing sits over whatever the page shows instead of a
        painted ground. Off by default, so the picture stays what it has
        always been.

    Returns
    -------
    bytes
        A complete GIF89a file.

    Raises
    ------
    ValueError
        If there are no frames, the rate is not positive, the antialias level
        is zero, or the designs' own colors (the inks and background) exceed
        256 between them -- which is GIF's limit, not this writer's.
    """
    if not frames:
        raise ValueError("cannot write a GIF with no frames")
    if fps <= 0:
        raise ValueError(f"fps must be > 0, got {fps}")
    if loop < 0:
        raise ValueError(f"loop must be >= 0, got {loop}")
    if aa_level < 1:
        raise ValueError(f"aa_level must be >= 1, got {aa_level}")

    seeds = colors_in(frames, ink=ink, background=background)
    if len(seeds) > 256:
        raise ValueError(
            f"a GIF has at most 256 colors and these designs need {len(seeds)}; "
            f"restyle them onto fewer, or write them as SVG instead"
        )
    shared = _union(frames)
    delay = max(_MIN_DELAY, round(100.0 / fps))

    if antialias:
        frame_rgba = [
            rasterize_rgba(
                frame,
                width=width,
                height=height,
                padding=padding,
                bounds=shared,
                ink=ink,
                background=background,
                thickness=thickness,
                dot_radius=dot_radius,
                aa_level=aa_level,
                transparent=transparent,
            )
            for frame in frames
        ]
        rasters = quantize(
            frame_rgba, seeds=seeds, max_colors=256, dither=dither, transparent=transparent
        )
        palette = rasters[0].palette
    else:
        palette = seeds
        rasters = tuple(
            rasterize(
                frame,
                width=width,
                height=height,
                padding=padding,
                bounds=shared,
                palette=palette,
                thickness=thickness,
                dot_radius=dot_radius,
            )
            for frame in frames
        )

    depth = _depth(len(palette))
    parts = [_header(width, height, depth), _color_table(palette, depth)]
    # loop=1 is the absence of the extension, not a count of one: the block
    # says how many times to repeat *after* the first play, and there is no
    # value of it that means "stop after one".
    if len(rasters) > 1 and loop != 1:
        parts.append(_looping(loop))
    parts.extend(
        _frame(raster, delay=delay, animated=len(rasters) > 1, transparent=transparent)
        for raster in rasters
    )
    parts.append(b";")
    return b"".join(parts)

to_jpeg

to_jpeg(source: Design | Raster, *, width: int = 480, height: int = 480, padding: float = 8.0, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, antialias: bool = False, aa_level: int = 8, quality: int = 85) -> bytes

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as JPEG.

A still is drawn once, exactly the way :func:to_png draws a frame, and then encoded with baseline JPEG: 4:2:0 chroma subsampling, an 8x8 DCT on each block, quality-scaled quantization, and Huffman coding against the reference tables. The styling parameters are shared with the PNG and GIF writers so one summoning controls all three.

Parameters:

Name Type Description Default
source Design or Raster

What to write. A design is drawn; a raster is encoded as it is.

required
width int

Canvas size in pixels. Ignored when source is already a raster.

480
height int

Canvas size in pixels. Ignored when source is already a raster.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
ink str

Default stroke color and the color behind everything.

'#0b0b0b'
background str

Default stroke color and the color behind everything.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
antialias bool

Supersample and blend edges. Off by default, so the edges are the same hard Bresenham edges they have always been.

False
aa_level int

How many shades an antialiased edge may blend into. A JPEG keeps full color anyway, so this only bounds the drawing, not the encode.

8
quality int

From 0 (smallest, most loss) to 100 (closest to the original). This is what the quantization tables are scaled by.

85

Returns:

Type Description
bytes

A complete baseline JPEG file.

Raises:

Type Description
ValueError

If quality is outside 0-100.

Source code in src/geomotif/io/jpeg.py
def to_jpeg(
    source: Design | Raster,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    antialias: bool = False,
    aa_level: int = 8,
    quality: int = 85,
) -> bytes:
    """Render a design -- or re-encode a :class:`~geomotif.io.Raster` -- as JPEG.

    A still is drawn once, exactly the way :func:`to_png` draws a frame, and
    then encoded with baseline JPEG: 4:2:0 chroma subsampling, an 8x8 DCT on
    each block, quality-scaled quantization, and Huffman coding against the
    reference tables. The styling parameters are shared with the PNG and GIF
    writers so one summoning controls all three.

    Parameters
    ----------
    source : Design or Raster
        What to write. A design is drawn; a raster is encoded as it is.
    width, height : int
        Canvas size in pixels. Ignored when ``source`` is already a raster.
    padding : float
        Margin reserved on all four sides, in pixels.
    ink, background : str
        Default stroke color and the color behind everything.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    antialias : bool
        Supersample and blend edges. Off by default, so the edges are the
        same hard Bresenham edges they have always been.
    aa_level : int
        How many shades an antialiased edge may blend into. A JPEG keeps full
        color anyway, so this only bounds the drawing, not the encode.
    quality : int
        From 0 (smallest, most loss) to 100 (closest to the original). This is
        what the quantization tables are scaled by.

    Returns
    -------
    bytes
        A complete baseline JPEG file.

    Raises
    ------
    ValueError
        If ``quality`` is outside 0-100.
    """
    if not _MIN_QUALITY <= quality <= _MAX_QUALITY:
        raise ValueError(
            f"quality must be between {_MIN_QUALITY} and {_MAX_QUALITY}, got {quality}"
        )

    rgb_frame = _rgb_frame(
        source,
        width=width,
        height=height,
        padding=padding,
        ink=ink,
        background=background,
        thickness=thickness,
        dot_radius=dot_radius,
        antialias=antialias,
        aa_level=aa_level,
    )
    return _encode(rgb_frame, quality)

to_plotter_svg

to_plotter_svg(design: Design, *, paper: str = 'a4', margin: float = 10.0, landscape: bool = False, stroke_width: float = DEFAULT_PEN, **kwargs: Any) -> str

Render a design as an SVG measured in real millimeters.

Parameters:

Name Type Description Default
design Design

What to plot.

required
paper str

A name from :data:PAPER.

'a4'
margin float

Border to leave unplotted, in millimeters. Worth more than you would think: most plotters cannot reach the last few millimeters of a sheet.

10.0
landscape bool

Turn the paper on its side.

False
stroke_width float

Pen width in millimeters. Only affects how the file looks -- a plotter draws with the pen it has -- but getting it right makes the preview honest.

DEFAULT_PEN
**kwargs Any

Passed to :func:~geomotif.io.svg.to_svg.

{}

Returns:

Type Description
str

An SVG whose width and height carry mm, whose viewBox is the same numbers, and whose layers are the groups vpype and Inkscape read.

Source code in src/geomotif/io/plotter.py
def to_plotter_svg(
    design: Design,
    *,
    paper: str = "a4",
    margin: float = 10.0,
    landscape: bool = False,
    stroke_width: float = DEFAULT_PEN,
    **kwargs: Any,
) -> str:
    """Render a design as an SVG measured in real millimeters.

    Parameters
    ----------
    design : Design
        What to plot.
    paper : str
        A name from :data:`PAPER`.
    margin : float
        Border to leave unplotted, in millimeters. Worth more than you would
        think: most plotters cannot reach the last few millimeters of a sheet.
    landscape : bool
        Turn the paper on its side.
    stroke_width : float
        Pen width in millimeters. Only affects how the file *looks* -- a
        plotter draws with the pen it has -- but getting it right makes the
        preview honest.
    **kwargs
        Passed to :func:`~geomotif.io.svg.to_svg`.

    Returns
    -------
    str
        An SVG whose ``width`` and ``height`` carry ``mm``, whose ``viewBox``
        is the same numbers, and whose layers are the groups ``vpype`` and
        Inkscape read.
    """
    width, height = page_size(paper, landscape=landscape)
    # The writer does the placing. Fitting to the page here as well would only
    # be undone: to_svg fits whatever it is given into the canvas it is given,
    # so a margin applied first is scaled straight back out to the paper edge.
    return to_svg(
        design,
        width=width,
        height=height,
        padding=margin,
        stroke_width=stroke_width,
        units="mm",
        **kwargs,
    )

to_png

to_png(source: Design | Raster, *, width: int = 480, height: int = 480, padding: float = 8.0, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, antialias: bool = False, aa_level: int = 8, color: str = 'rgb', compression: int = 6, transparent: bool = False) -> bytes

Render a design -- or re-encode a :class:~geomotif.io.Raster -- as PNG.

A still is drawn once, exactly the way :func:to_gif draws a frame, and then written as a PNG instead of being quantized toward a GIF's colors. The styling parameters are shared with the GIF writer so one summoning controls both.

Parameters:

Name Type Description Default
source Design or Raster

What to write. A design is drawn; a raster is encoded as it is.

required
width int

Canvas size in pixels. Ignored when source is already a raster.

480
height int

Canvas size in pixels. Ignored when source is already a raster.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
ink str

Default stroke color and the color behind everything.

'#0b0b0b'
background str

Default stroke color and the color behind everything.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
antialias bool

Supersample and blend edges. Off by default, so the edges are the same hard Bresenham edges they have always been.

False
aa_level int

When antialiasing into an indexed frame, how many shades an edge may blend into per color pair. Ignored for the truecolor paths, which keep every level.

8
color str

"rgb", "rgba" or "indexed" -- how the PNG stores the picture. See the module docstring for what each means.

'rgb'
compression int

zlib level from 0 (fast, big) to 9 (slow, small). Must be an integer in that range.

6
transparent bool

Leave the background empty instead of painting background. A pixel with no ink is written with alpha 0 and an antialiased edge with the ink and its coverage as the alpha -- the straight alpha a PNG stores. This implies color="rgba" (a JPEG has no alpha, so this is where transparency lives in this library). Off by default.

False

Returns:

Type Description
bytes

A complete PNG file.

Raises:

Type Description
ValueError

If color is not one of the three, compression is out of range, or an indexed frame would need more than 256 palette colors.

Source code in src/geomotif/io/png.py
def to_png(
    source: Design | Raster,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    antialias: bool = False,
    aa_level: int = 8,
    color: str = "rgb",
    compression: int = 6,
    transparent: bool = False,
) -> bytes:
    """Render a design -- or re-encode a :class:`~geomotif.io.Raster` -- as PNG.

    A still is drawn once, exactly the way :func:`to_gif` draws a frame, and
    then written as a PNG instead of being quantized toward a GIF's colors.
    The styling parameters are shared with the GIF writer so one summoning
    controls both.

    Parameters
    ----------
    source : Design or Raster
        What to write. A design is drawn; a raster is encoded as it is.
    width, height : int
        Canvas size in pixels. Ignored when ``source`` is already a raster.
    padding : float
        Margin reserved on all four sides, in pixels.
    ink, background : str
        Default stroke color and the color behind everything.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    antialias : bool
        Supersample and blend edges. Off by default, so the edges are the
        same hard Bresenham edges they have always been.
    aa_level : int
        When antialiasing into an indexed frame, how many shades an edge
        may blend into per color pair. Ignored for the truecolor paths,
        which keep every level.
    color : str
        ``"rgb"``, ``"rgba"`` or ``"indexed"`` -- how the PNG stores the
        picture. See the module docstring for what each means.
    compression : int
        zlib level from 0 (fast, big) to 9 (slow, small). Must be an
        integer in that range.
    transparent : bool
        Leave the background empty instead of painting ``background``. A
        pixel with no ink is written with alpha 0 and an antialiased edge
        with the ink and its coverage as the alpha -- the straight alpha a
        PNG stores. This implies ``color="rgba"`` (a JPEG has no alpha, so
        this is where transparency lives in this library). Off by default.

    Returns
    -------
    bytes
        A complete PNG file.

    Raises
    ------
    ValueError
        If ``color`` is not one of the three, ``compression`` is out of
        range, or an indexed frame would need more than 256 palette colors.
    """
    if color not in _COLOR_TYPE:
        raise ValueError(f"color must be one of {sorted(_COLOR_TYPE)}, got {color!r}")
    if not _MIN_COMPRESSION <= compression <= _MAX_COMPRESSION:
        raise ValueError(
            f"compression must be between {_MIN_COMPRESSION} and {_MAX_COMPRESSION}, "
            f"got {compression}"
        )
    if transparent:
        color = "rgba"

    raster = _frame(
        source,
        color=color,
        width=width,
        height=height,
        padding=padding,
        ink=ink,
        background=background,
        thickness=thickness,
        dot_radius=dot_radius,
        antialias=antialias,
        aa_level=aa_level,
        transparent=transparent,
    )
    return _encode(raster, _COLOR_TYPE[color], compression)

to_spec

to_spec(source: SupportsBuild | Design, *, animation: Mapping[str, object] | None = None) -> dict[str, object]

Return the JSON-ready recipe for a motif, or for the design it built.

Parameters:

Name Type Description Default
source Motif or Design

A motif, or any design whose :attr:~geomotif.Design.meta records the motif that produced it -- which every builtin motif's does.

required
animation mapping

An animation recipe to carry alongside the still, so a moving picture round-trips through the same file the CLI's --animation flag reads. The value is written verbatim under :data:ANIMATION_KEY; a still spec simply omits the key.

None

Returns:

Type Description
dict

{"geomotif": version, "motif": name, "params": {...}}, holding only JSON types and ready for :func:json.dumps. When animation is given, an "animation" key sits beside them.

Raises:

Type Description
ValueError

If source is a design with no motif recorded in its metadata.

TypeError

If a parameter cannot be written as data -- see the module docstring.

Examples:

>>> from geomotif.motifs import Star
>>> to_spec(Star(points=7))["motif"]
'star'
Source code in src/geomotif/io/spec.py
def to_spec(
    source: SupportsBuild | Design, *, animation: Mapping[str, object] | None = None
) -> dict[str, object]:
    """Return the JSON-ready recipe for a motif, or for the design it built.

    Parameters
    ----------
    source : Motif or Design
        A motif, or any design whose :attr:`~geomotif.Design.meta` records the
        motif that produced it -- which every builtin motif's does.
    animation : mapping, optional
        An animation recipe to carry alongside the still, so a moving picture
        round-trips through the same file the CLI's ``--animation`` flag reads.
        The value is written verbatim under :data:`ANIMATION_KEY`; a still
        spec simply omits the key.

    Returns
    -------
    dict
        ``{"geomotif": version, "motif": name, "params": {...}}``, holding only
        JSON types and ready for :func:`json.dumps`. When ``animation`` is
        given, an ``"animation"`` key sits beside them.

    Raises
    ------
    ValueError
        If ``source`` is a design with no motif recorded in its metadata.
    TypeError
        If a parameter cannot be written as data -- see the module docstring.

    Examples
    --------
    >>> from geomotif.motifs import Star
    >>> to_spec(Star(points=7))["motif"]
    'star'
    """
    live = _live_spec(source)
    name = live[registry.NAME_KEY]
    params = {
        key: value
        for key, value in live.items()
        if key != registry.NAME_KEY and key not in _STYLE_KEYS
    }
    # Imported at call time rather than at module scope: this module is part of
    # the package whose version it reads, so the two would import in a cycle.
    from .. import __version__

    blob: dict[str, object] = {
        VERSION_KEY: __version__,
        registry.NAME_KEY: name,
        PARAMS_KEY: _encode(params, where=str(name)),
    }
    # Styles sit beside the parameters rather than among them: they belong to
    # the design rather than to the motif, and feeding one back to a
    # constructor as a keyword argument would only raise.
    for key in _STYLE_KEYS:
        if key in live:
            blob[key] = _encode(live[key], where=key)
    if animation is not None:
        blob[ANIMATION_KEY] = dict(animation)
    return blob

to_svg

to_svg(design: Design, *, width: float | None = None, height: float | None = None, padding: float = 8.0, stroke: str = '#0b0b0b', stroke_width: float = 1.0, fill: str = 'none', background: str | None = None, dot_radius: float | None = None, flip_y: bool = True, precision: int = 3, group_by_path: bool = True, title: str | None = None, units: str = '') -> str

Render a design as an SVG document.

Parameters:

Name Type Description Default
design Design

What to draw. Its strokes become <path> elements and its loose points become <circle> elements.

required
width float

Canvas size in user units. Give both to fit the design into exactly that rectangle; give one and the other follows from the design's own proportions; give neither and the design keeps its own measurements, with padding added around it.

None
height float

Canvas size in user units. Give both to fit the design into exactly that rectangle; give one and the other follows from the design's own proportions; give neither and the design keeps its own measurements, with padding added around it.

None
padding float

Margin reserved on all four sides.

8.0
stroke (str, float, str)

Applied to the group holding the strokes. fill="none" is the default because most of this catalog is line work; name a color to fill the closed paths instead.

'#0b0b0b'
stroke_width (str, float, str)

Applied to the group holding the strokes. fill="none" is the default because most of this catalog is line work; name a color to fill the closed paths instead.

'#0b0b0b'
fill (str, float, str)

Applied to the group holding the strokes. fill="none" is the default because most of this catalog is line work; name a color to fill the closed paths instead.

'#0b0b0b'
background str

Draw a filled rectangle behind everything. Omitted by default, which leaves the canvas transparent.

None
dot_radius float

Radius for the loose points. Defaults to stroke_width, so dots read about as heavy as lines; pass 0 to leave them out entirely.

None
flip_y bool

Mirror vertically, so a design drawn y-up appears the right way up in SVG's y-down space. On by default.

True
precision int

Decimal places for coordinates. Trailing zeros are dropped, so a whole number costs one character rather than five.

3
group_by_path bool

Give every stroke its own <path> element, so an editor treats them as separate objects. Turn it off to merge them into one element with several subpaths, which is smaller but arrives as a single shape.

True
title str

The document's <title>. Defaults to the motif recorded in the design's metadata, which is what makes a gallery file self-labelling.

None
units str

A physical unit for the document's width and height, from :data:UNITS -- "mm", "in", "pt" and the rest. The viewBox stays in plain numbers, so one user unit becomes one of these and the drawing has a real size on paper. Empty by default, which leaves the size in user units and is what anything on a screen wants. See :mod:geomotif.io.plotter.

''

Returns:

Type Description
str

A complete SVG document, ending in a newline.

Raises:

Type Description
ValueError

If the design has no points, padding leaves no room inside the canvas asked for, or units is not one of :data:UNITS.

Source code in src/geomotif/io/svg.py
def to_svg(
    design: Design,
    *,
    width: float | None = None,
    height: float | None = None,
    padding: float = 8.0,
    stroke: str = "#0b0b0b",
    stroke_width: float = 1.0,
    fill: str = "none",
    background: str | None = None,
    dot_radius: float | None = None,
    flip_y: bool = True,
    precision: int = 3,
    group_by_path: bool = True,
    title: str | None = None,
    units: str = "",
) -> str:
    """Render a design as an SVG document.

    Parameters
    ----------
    design : Design
        What to draw. Its strokes become ``<path>`` elements and its loose
        points become ``<circle>`` elements.
    width, height : float, optional
        Canvas size in user units. Give both to fit the design into exactly
        that rectangle; give one and the other follows from the design's own
        proportions; give neither and the design keeps its own measurements,
        with ``padding`` added around it.
    padding : float, optional
        Margin reserved on all four sides.
    stroke, stroke_width, fill : str, float, str, optional
        Applied to the group holding the strokes. ``fill="none"`` is the
        default because most of this catalog is line work; name a color to
        fill the closed paths instead.
    background : str, optional
        Draw a filled rectangle behind everything. Omitted by default, which
        leaves the canvas transparent.
    dot_radius : float, optional
        Radius for the loose points. Defaults to ``stroke_width``, so dots
        read about as heavy as lines; pass ``0`` to leave them out entirely.
    flip_y : bool, optional
        Mirror vertically, so a design drawn y-up appears the right way up in
        SVG's y-down space. On by default.
    precision : int, optional
        Decimal places for coordinates. Trailing zeros are dropped, so a whole
        number costs one character rather than five.
    group_by_path : bool, optional
        Give every stroke its own ``<path>`` element, so an editor treats them
        as separate objects. Turn it off to merge them into one element with
        several subpaths, which is smaller but arrives as a single shape.
    title : str, optional
        The document's ``<title>``. Defaults to the motif recorded in the
        design's metadata, which is what makes a gallery file self-labelling.
    units : str, optional
        A physical unit for the document's ``width`` and ``height``, from
        :data:`UNITS` -- ``"mm"``, ``"in"``, ``"pt"`` and the rest. The
        ``viewBox`` stays in plain numbers, so one user unit becomes one of
        these and the drawing has a real size on paper. Empty by default,
        which leaves the size in user units and is what anything on a screen
        wants. See :mod:`geomotif.io.plotter`.

    Returns
    -------
    str
        A complete SVG document, ending in a newline.

    Raises
    ------
    ValueError
        If the design has no points, ``padding`` leaves no room inside the
        canvas asked for, or ``units`` is not one of :data:`UNITS`.
    """
    if not len(design):
        raise ValueError("cannot write an empty design to SVG: there is nothing to draw")
    if padding < 0:
        raise ValueError(f"padding must be >= 0, got {padding}")
    if precision < 0:
        raise ValueError(f"precision must be >= 0, got {precision}")
    if units not in UNITS:
        raise ValueError(f"units must be one of {UNITS}, got {units!r}")

    canvas_w, canvas_h = _canvas(design, width, height, padding)
    placed = design.fit(canvas_w, canvas_h, padding=padding, flip_y=flip_y)
    radius = stroke_width if dot_radius is None else dot_radius
    layers = by_layer(placed) if layer_names(placed) else {None: placed}

    def num(value: float) -> str:
        return _num(value, precision)

    namespaces = f'xmlns="{SVG_NS}"'
    if any(name is not None for name in layers):
        namespaces += f' xmlns:inkscape="{INKSCAPE_NS}"'
    lines = [
        '<?xml version="1.0" encoding="UTF-8"?>',
        f'<svg {namespaces} width="{num(canvas_w)}{units}" '
        f'height="{num(canvas_h)}{units}" viewBox="0 0 {num(canvas_w)} {num(canvas_h)}">',
    ]

    label = title if title is not None else str(design.meta.get(NAME_KEY, "") or "")
    if label:
        lines.append(f"  <title>{escape(label)}</title>")
    if background is not None:
        lines.append(
            f'  <rect width="{num(canvas_w)}" height="{num(canvas_h)}" '
            f"fill={quoteattr(background)}/>"
        )

    defaults = _Ink(stroke=stroke, stroke_width=stroke_width, fill=fill, radius=radius)
    for name, part in layers.items():
        body = _elements(
            part,
            defaults,
            indent=1 if name is None else 2,
            precision=precision,
            group_by_path=group_by_path,
        )
        if name is None:
            lines.extend(body)
            continue
        # Inkscape's own attributes, which is also what vpype reads a layer
        # from. Anything else opens the file as a plain group and loses only
        # the name.
        lines.append(
            f'  <g inkscape:groupmode="layer" inkscape:label={quoteattr(name)} '
            f"id={quoteattr(name)}>"
        )
        lines.extend(body)
        lines.append("  </g>")

    lines.append("</svg>")
    return "\n".join(lines) + "\n"