Skip to content

geomotif.core.sampling

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

This is the engine that makes "equal spacing" mean equal real distance rather than equal steps of some parameter. A curve is measured with a dense polyline, the cumulative lengths are tabulated, and that table is inverted so a requested fraction of the total length lands exactly where it should.

Because it operates on polylines rather than on any particular curve, every motif in the library -- including fractals, tilings and string art, which have no closed-form parametrization at all -- gets arc-length placement and the whole spacing-curve family for free.

It is also plain Python on tuples of floats, deliberately. An array library was tried here and lost: converting a design costs more than a single pass over its vertices saves, and its hypot disagrees with :func:math.dist in the last bit, which would move every point this table places.

Classes:

Name Description
ArcTable

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

Functions:

Name Description
samples_for_turns

Return a sensible densification count for a curve spanning turns.

densify

Evaluate fn at evenly spaced parameters across domain.

resample_path

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

resample

Return design resampled across all of its paths.

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

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

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

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