Skip to content

geomotif.core.types

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

A :class:Design is the universal currency of this library: zero or more stroked :class:Path polylines plus zero or more loose :class:Point s that carry no stroke (dot art, scatter fields, lattice sites). Motifs build them, transforms rewrite them, exporters write them out.

Everything here is immutable, so designs compose without aliasing surprises and can be shared freely between threads. Operations that would mutate return a new value instead -- :meth:Design.transformed, :meth:Design.resampled, :meth:Design.fit.

Classes:

Name Description
Bounds

An axis-aligned rectangle enclosing some geometry.

Path

One continuous polyline.

Design

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

Functions:

Name Description
select_styles

Return meta with its style lists following reshaped geometry.

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

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.

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)

select_styles

select_styles(meta: Mapping[str, object], *, paths: Sequence[int] | None = None, points: Sequence[int] | None = None) -> Mapping[str, object]

Return meta with its style lists following reshaped geometry.

Any operator that drops, splits or reorders a design's strokes has to say so, or the metadata it carries over lands on the wrong geometry -- a clipped design whose colors have all shifted by one. paths and points give the source index of every element the result keeps, in the order it keeps them, which is something every such operator knows.

Parameters:

Name Type Description Default
meta mapping

The metadata to rewrite. Returned unchanged, and uncopied, when it carries no styles at all -- which is the usual case.

required
paths sequence of int

Source indices, one per element of the result. None leaves that list alone, which is the right answer for an operation that reshaped only the other one.

None
points sequence of int

Source indices, one per element of the result. None leaves that list alone, which is the right answer for an operation that reshaped only the other one.

None

Returns:

Type Description
Mapping[str, object]

Ready to hand to :class:Design. A rewritten mapping is read-only; a styleless one is the argument itself, so it is exactly as read-only as what was passed in.

Source code in src/geomotif/core/types.py
def select_styles(
    meta: Mapping[str, object],
    *,
    paths: Sequence[int] | None = None,
    points: Sequence[int] | None = None,
) -> Mapping[str, object]:
    """Return ``meta`` with its style lists following reshaped geometry.

    Any operator that drops, splits or reorders a design's strokes has to say
    so, or the metadata it carries over lands on the wrong geometry -- a
    clipped design whose colors have all shifted by one. ``paths`` and
    ``points`` give the *source* index of every element the result keeps, in
    the order it keeps them, which is something every such operator knows.

    Parameters
    ----------
    meta : mapping
        The metadata to rewrite. Returned unchanged, and uncopied, when it
        carries no styles at all -- which is the usual case.
    paths, points : sequence of int, optional
        Source indices, one per element of the result. ``None`` leaves that
        list alone, which is the right answer for an operation that reshaped
        only the other one.

    Returns
    -------
    Mapping[str, object]
        Ready to hand to :class:`Design`. A rewritten mapping is read-only; a
        styleless one is the argument itself, so it is exactly as read-only as
        what was passed in.
    """
    if PATH_STYLE_KEY not in meta and POINT_STYLE_KEY not in meta:
        return meta
    updated = dict(meta)
    for key, indices in ((PATH_STYLE_KEY, paths), (POINT_STYLE_KEY, points)):
        stored = _style_list(meta, key)
        if indices is None or stored is None:
            continue
        updated[key] = tuple(
            stored[index] if 0 <= index < len(stored) else None for index in indices
        )
    return MappingProxyType(updated)