Skip to content

geomotif.core.style

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

Geometry says where the ink goes. A :class:Style says which pen puts it there -- a layer name, a color, a stroke width -- and it rides along in :attr:Design.meta rather than in :class:~geomotif.Path, because none of it changes the maths. A design keeps its styles through every transform, every resample and every overlay, and drops them the moment you ask for the coordinates alone.

Layers are the part that earns its keep. A pen plotter draws one pen at a time, so a two-color drawing is two files or one file with two layers; the SVG writer emits the groups Inkscape and vpype already understand, and the DXF writer emits real DXF layers::

from geomotif import layer, save_svg, styled
from geomotif.motifs import Circle, Phyllotaxis

outline = styled(Circle(radius=100).build(), layer="pen1", stroke="black")
seeds = styled(Phyllotaxis().build(), layer="pen2", stroke="crimson")
save_svg(layer(outline, seeds), "two-pens.svg")

Nothing is required to have a style, and a design without one writes exactly the file it wrote before this module existed.

Classes:

Name Description
Style

How one stroke or one loose point is drawn.

Functions:

Name Description
styled

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

styles_of

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

point_styles_of

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

layer_names

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

by_layer

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

Style dataclass

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

How one stroke or one loose point is drawn.

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

Parameters:

Name Type Description Default
layer str

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

None
stroke str

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

None
width float

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

None
fill str

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

None

Methods:

Name Description
merged

Return this style with everything other states laid over it.

merged

merged(other: Style | None) -> Style

Return this style with everything other states laid over it.

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

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

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

styled

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

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

Parameters:

Name Type Description Default
design Design

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

required
style Style

A whole style to apply.

None
layer str | None

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

None
stroke str | None

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

None
width str | None

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

None
fill str | None

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

None

Returns:

Type Description
Design

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

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

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

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

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

styles_of

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

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

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

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

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

point_styles_of

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

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

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

layer_names

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

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

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

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

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

by_layer

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

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

Returns:

Type Description
dict

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

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

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

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

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