Skip to content

geomotif.core

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

Everything here is motif-agnostic. Concrete geometry lives in :mod:geomotif.motifs; this package is what makes writing one cheap.

Modules:

Name Description
motif

The motif contract: the one thing you implement to extend the library.

range

Concise min/max/step metadata for a motif parameter.

registry

Motif registration, lookup and introspection.

sampling

Arc-length measurement and resampling, generalized to any polyline.

spacing

Spacing curves that control the distribution of points along a path.

style

color and layers: how a design is drawn, rather than what it is.

transform

Affine transforms and the composite operators built on them.

types

The geometric value types every motif produces and every tool consumes.

Classes:

Name Description
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.

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

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.

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)

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.

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}"
    )

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)