Skip to content

geomotif.core.spacing

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

A spacing curve is a monotonic mapping of normalized progress t in [0, 1] to eased progress in [0, 1], with f(0) == 0 and f(1) == 1. The eased value selects each point's position as a fraction of the path's arc length, so the shape of the curve directly controls the gap between consecutive points:

  • f(t) = t -> equal spacing
  • slow start / fast end -> spacing gradually increases
  • fast start / slow end -> spacing gradually decreases

Because resampling is generic over polylines, these curves apply to every motif in the library -- spirals, fractals, tilings and string art alike.

Classes:

Name Description
SpacingCurve

Base class for point-spacing curves.

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

CubicSpacing

Classic cubic easing (t ** 3).

SineSpacing

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

ExponentialSpacing

Exponential easing with adjustable strength (dramatic bias).

CircularSpacing

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

SmoothstepSpacing

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

ReversedSpacing

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

CompositeSpacing

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

TableSpacing

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

Functions:

Name Description
coerce_spacing

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

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

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)

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)

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

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

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

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.

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)

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)

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)

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