Skip to main content

geomotif

Generate and plot geometric designs, and control exactly where the points along them land.

golden spiral Maurer rose Hilbert curve Penrose P3 tiling Celtic knot plait mandala

PyPI Python versions MIT license

pip install geomotif

Zero dependencies for the core, Python 3.12+. matplotlib and scipy are optional extras, and nothing on the path from a motif to an SVG file needs either.

Documentation · Gallery · Catalogue · Changelog


The whole mental model

A motif is a parameterized recipe for geometry. Applying a transform to what it produced gives a design, which is what you plot or export.

from geomotif import PowerSpacing
from geomotif.motifs import SpiralBetween

spiral = SpiralBetween(
    start=(200, 0),  # required — first point (always included)
    end=(20, 0),  # required — last point (always included)
    center=(0, 0),  # point the spiral winds around (default shown)
    turns=3,  # extra full revolutions (default 0)
)

design = spiral.generate(100, spacing=PowerSpacing(2.5))

for x, y in design:
    ...

build() gives a motif at its native resolution; generate() gives you the points you actually plot. Everything after the count is keyword-only:

spiral.generate(100)  # 100 points, equally spaced
spiral.generate(100, spacing=PowerSpacing(2.5))  # eased distribution
spiral.generate(step=5.0)  # a point every 5 units of real distance
spiral.generate(100, by="parameter")  # parametric instead of arc-length

Why arc length is the point

Most plotting code spaces points by parameter — equal steps through whatever variable the formula happens to use. On a spiral that puts the points bunched up at the tight end and stretched out at the wide end, which is not what "100 evenly spaced points" is supposed to mean.

geomotif measures the curve with a dense polyline, builds a cumulative-length table, and inverts it. Equal spacing means the same real x,y distance between every consecutive pair of points, however tightly the curve winds.

Because that engine works on polylines rather than on formulas, it applies to every motif in the catalogue — including the ones with no closed-form parametrization at all — and to yours. step= is the mode you want for plotter output and dot placement: the gap is fixed and the count falls out of the geometry.

→ Where the points land

Write your own motif

Usually it is the maths and nothing else. Pick the base that matches how your design is defined and write the one method it asks for:

import math
from dataclasses import dataclass

from geomotif import PolarMotif, register


@register("my-flower", family="polar")
@dataclass(frozen=True, slots=True)
class MyFlower(PolarMotif):
    """A seven-lobed flower with a ripple on it."""

    k: float = 7.0

    def radius(self, theta: float) -> float:
        return math.sin(self.k * theta) + 0.4 * math.cos(17 * theta)

Six lines of substance, and that class now has arc-length resampling, every spacing curve, the transform layer, export to five formats, spec serialization, generated command-line flags, lookup by name and the whole conformance suite. MyFlower(k=5).generate(400) works, and so does geomotif render my-flower --k 5 --out flower.svg.

Base You implement
PolarMotif radius(theta) -> float
ParametricMotif position(u) -> Point
MultiCurveMotif curves() -> Iterable[Curve]
PolygonMotif outlines() -> Iterable[Sequence[Point]]
SegmentMotif nodes() and edges()
LSystemMotif an axiom, rewrite rules and a turn angle
LatticeTiling cell() and basis()
SubstitutionTiling seed(), subdivide() and outline()

The one distinction worth getting right is ParametricMotif vs PolygonMotif: a curve is measured at evenly spaced parameters, a polygon is listed. Measuring a pentagon at 512 samples rounds all five of its corners off, so shapes defined by their corners list them instead.

If none of them fits, subclass Motif and write build() by hand — one method, returning a Design. You are not required to inherit at all: anything with a build() -> Design method satisfies the SupportsBuild protocol and is accepted everywhere a motif is.

Shipping it as a plugin

One entry point in your pyproject.toml is the whole contract:

[project.entry-points."geomotif.motifs"]
my_motifs = "my_package:register_all"

geomotif reads that group the first time anything touches its registry, so a plugin nobody uses costs nothing to have installed. Once yours is installed it is indistinguishable from a builtin: geomotif list shows it, geomotif show documents it, geomotif render renders it with flags generated from your fields, it serializes to a spec, and the conformance suite runs against it.

examples/plugin/ is a complete worked one — Gielis's superformula as an installable package, in about forty lines. CI installs it into a clean environment on every push, to prove the discovery half works outside the test suite.

→ Extending geomotif

What's in the box

146 motifs in 18 families — the full catalogue, and the gallery with a picture of every one.

Family What is in it
spiral 11 Archimedean, logarithmic, golden, Fibonacci (the quarter-arc approximation everyone actually draws — both are here because they are not the same curve), Fermat, hyperbolic, lituus, Theodorus, Euler/clothoid, circle involute, and the endpoint-to-endpoint SpiralBetween
primitive 16 circle, ellipse, arc, sector, line, rectangle, rounded rectangle, regular polygon, star polygon (the {n/k} family — {6/2} correctly comes back as two triangles), superellipse, squircle, Reuleaux polygon, egg, point grid, Poisson disc
curve 18 heart, cardioid, lemniscate, Cassini oval (two strokes when it really is two lobes), limaçon, butterfly, fish, bow, astroid, deltoid, nephroid, folium, cochleoid, cycloid, trochoid, witch of Agnesi, cornoid
roulette 6 hypotrochoid, epitrochoid, hypocycloid, epicycloid, Spirograph in the toy's own terms, and Epicycles — two arms is a trochoid, several dozen is a Fourier series
polar, harmonic 7 rose (with the petal count right: n or 2n, by a parity rule most implementations get wrong), Maurer rose, Lissajous, harmonic, harmonograph, phyllotaxis, and a one-off radius function
fractal 23 Koch, Minkowski, Sierpiński, dragon, Lévy C, Hilbert, Moore, Peano, Gosper, Vicsek — sixteen of them a grammar and nothing else — plus the carpet, Cantor set, Pythagoras tree, H-tree, Apollonian gasket (Descartes' circle theorem, so the curvatures come out integral), and two by chaos game
graph 7 complete, cyclic/circulant, bipartite, chord diagram, prime chords, and the modular times-table cardioid
string-art 3 the corner parabola everybody has made, the polygon and circle versions, and the general envelope engine the rest are special cases of
tiling 12 square, triangular, hexagonal, rhombille, Cairo pentagonal, truncated square, snub square, herringbone at any brick proportion; Penrose P3 and P2, Ammann–Beenker; and Truchet, which tosses a coin per cell
sacred 7 one construction, five figures: vesica → seed → flower → fruit → Metatron's cube, plus Sri Yantra and the golden rectangle
guilloche 3 the engine-turned line work of banknotes and watch dials
girih 6 the five Islamic strapwork tiles, tenfold layouts, Hankin's rule applied until the tiles vanish and ten-pointed stars are left, and the shamsa rosettes
knot 5 triquetra, endless knot, circular and square Celtic knots, and the plait every knotwork panel is built on — over-and-under worked out rather than declared
solid 7 the five Platonic solids and the truncated icosahedron as wireframes, through an orthographic, isometric or perspective projection
illusion 6 Penrose triangle and stairs, impossible cube, Necker cube, café wall, moiré. The two Penrose figures are built in space and then flattened: their walks genuinely fail to close, by exactly the amount an isometric view cannot show
voronoi 4 Delaunay, Voronoi, cells with an optional inset, and Lloyd's relaxation. The only motifs with a dependency — they declare requires="scipy", so a machine without it can still list and describe them
mandala 5 the composers: rings of a repeated unit, a kaleidoscope under a Cn/Dn group, a snowflake grown from a seed, spokes and layered rings — whose unit is any object with a build() method, including yours

Anything registered is reachable by name, with its parameters introspectable:

from geomotif.core import registry

registry.families()  # ('curve', 'fractal', 'girih', 'graph', 'guilloche', ...)
registry.names(family="spiral")  # ('spiral.archimedean', 'spiral.between', ...)
registry.create("polygon.star", points=7, step=3)
registry.describe("egg").params  # name, type, default for each
registry.describe("voronoi.diagram").available  # False without the [scipy] extra

Designs, paths and points

A Design holds stroked Path polylines plus loose points that carry no stroke (dot art, scatter fields). It iterates as a flat stream of points, so it drops straight into anything expecting coordinates:

len(design)  # total point count
design.bounds  # Bounds(min_x, min_y, max_x, max_y)
design + other  # overlay
design.fit(800, 600, padding=20)  # scale and center onto a canvas
design.flipped_y()  # y-down (screen/SVG) coordinates

Everything is immutable — operations return a new design — and NaN and infinity are rejected at construction rather than propagating silently into your output.

import math

from geomotif import Affine, jitter, radial_repeat, tile

rosette = radial_repeat(petal, 12)  # the mandala workhorse
lattice = tile(cell, 8, 8, dx=20, dy=20, stagger=0.5)
turned = design.transformed(Affine.rotate(math.pi / 6))
loose = jitter(design, 0.5, seed=7)  # reproducible irregularity

Affine composes with @ — (m @ n)(p) == m(n(p)), so the right-hand transform applies first.

→ Designs, paths and transforms

Exporting

from geomotif import save_design, save_dxf, save_points, save_spec, save_svg

save_points(design, "points.csv")  # x,y — for anything that just wants numbers
save_design(design, "design.txt")  # strokes kept apart, a blank line between them
save_svg(design, "design.svg", width=800)  # anything that displays
save_dxf(design, "design.dxf", layer="CUTS")  # anything that cuts, mills or plots
save_spec(motif, "design.json")  # the recipe, not the points

Both the SVG and DXF writers are pure standard library — the core stays dependency-free all the way out to the file. SVG fits the design into the canvas before writing, so stroke_width means one unit of the file you are looking at; DXF is R12, using POLYLINE/VERTEX rather than the R14-era LWPOLYLINE, because R12 is the version everything reads.

A spec records the motif and its parameters instead of the geometry they produced. It survives a change of point count, it is a file you can edit by hand, and it is a great deal smaller — a mandala's recipe is 1.5 KB against 330 KB of coordinates:

{
  "geomotif": "1.0.0",
  "motif": "spiral.fibonacci",
  "params": { "quarters": 9, "size": 10.0 }
}

A parameter that is itself a motif — the composers take one — nests as the same object, so a mandala's rings serialize without a second notation. Every motif in the catalogue round-trips exactly, bar the two whose parameter is a Python function: those are defined by code, not data, and say so when asked. Loading a spec never imports a module the file names, only value types from packages that already provide motifs here.

→ Exporting

Command line

geomotif list                                   # every motif, grouped by family
geomotif show rose                              # docs, parameters, defaults
geomotif render rose --n 5 --samples 400 --out rose.svg
geomotif render spiral.golden --samples 300 --ease power:2.5 --out s.csv
geomotif render fractal.hilbert --depth 6 --out h.dxf --fit 800x800
geomotif render --spec my-design.json --out out.svg
geomotif gallery --out gallery                  # all 146, plus a manifest
geomotif demo

Pure argparse, so the core stays dependency-free. A motif's flags come from its dataclass fields — the same declaration that drives describe() and the spec format. Two consequences worth knowing:

  • Not every parameter can be said on a command line. A motif taking a Python function, another motif, or a point set has no sensible flag; those take their value from the motif's registered example, so all 146 render. geomotif render voronoi.cells --inset 0.2 works — the point set is the example's, the inset is yours.
  • The sampling options are --samples, --stride and --ease, not the more obvious words: points, count, step and spacing are all motif parameter names already, and argparse has one namespace.

Without --out the points go to stdout as CSV, so the command pipes.

→ The command line

Plotting

To see the points on a graph (requires the plot extra):

import matplotlib.pyplot as plt

from geomotif.plotting import DARK, plot_comparison, plot_design

plot_design(design, show_points=True, center=(0, 0), title="my spiral")
plt.show()

plot_comparison is the library's premise in one figure — one motif, one point count, several spacing curves. plot_grid draws several designs side by side, and every function takes a palette= (LIGHT or DARK), so a dark-mode figure is a different argument rather than a different code path.

Spacing curves

All curves subclass SpacingCurve — implement ease(t) mapping [0, 1] → [0, 1] to make your own; any plain callable works too. Most take mode="in" (spacing gradually increases), "out" (gradually decreases) or "in_out".

Curve Character
LinearSpacing() equal spacing (default)
PowerSpacing(exponent, mode) general "by how much" control; 1 = equal
QuadraticSpacing(mode) classic t² easing
CubicSpacing(mode) classic t³ easing
SineSpacing(mode) gentle bias
ExponentialSpacing(mode, strength) dramatic clustering, tunable
CircularSpacing(mode) quarter-arc profile
SmoothstepSpacing() inherently in-out, dense at both ends
ReversedSpacing(curve) mirror any curve, including plain callables
CompositeSpacing(*curves) chain eases left to right
TableSpacing(points) draw the curve by hand from control points

Notes on geometry

  • Angles use the standard math convention (y-up). For a coordinate system whose y-axis points down (screen/raster style), call design.flipped_y() or pass flip_y=True to fit.
  • Spacing is measured in distance along the curve. When gaps are small relative to the local radius (the usual case) the straight-line distance between neighbours is effectively identical; only a gap that curls around a large fraction of a tight turn dips noticeably below its along-curve length.
  • by="parameter" restores parametric spacing, which visually compresses toward tight sections — occasionally useful as a design effect.
  • Degenerate inputs are handled gracefully: an endpoint on the centre yields a radial line, and identical start and end with turns=0 yields coincident points.

Development

git clone https://github.com/pianosuki/geomotif && cd geomotif
pip install -e . --group dev    # editable install + pytest, matplotlib, scipy
make check                      # ruff, ruff-format, mypy strict, pytest
make docs-serve                 # the documentation site, with live reload

make docs-gen regenerates the derived documentation and make docs-check fails if the committed part of it has fallen behind the code. Both run in CI, along with the test suite on 3.12/3.13/3.14 across Linux, macOS and Windows, a job that installs the package with no extras at all, and a job that installs examples/plugin/ into a clean environment.

License

MIT

Metadata

Release files for geomotif 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for geomotif 1.0.0
File Size Uploaded
geomotif-1.0.0.tar.gz 446.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for geomotif 1.0.0
File Interpreter ABI Platform
geomotif-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 643.6 kB

Release files / geomotif-1.0.0.tar.gz

Download URL geomotif-1.0.0.tar.gz
Size 446.2 kB
Tags Source
SHA-256 checksum
How to use checksums
bf12ac024338164a45d818f24e3364e8d81f1d4be38c980fc066f81859b19b66
BLAKE2b-256 checksum
How to use checksums
e696f7baba7d771832a9363f89b2779507f788589ca2365a1f92a1f40f01f1d4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 28, 2026.

Transparency log

Release files / geomotif-1.0.0-py3-none-any.whl

Download URL geomotif-1.0.0-py3-none-any.whl
Size 197.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
02cd5be61d7a43259219fcf47f6c8d49caa3d56c6738c885249cd6ce8622ffe2
BLAKE2b-256 checksum
How to use checksums
d974342b99a5d809ff63fb4f8d1e368e56e93b58b77099d4423cefccf05b9feb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 28, 2026.

Transparency log

Release history Release notifications | RSS feed

1.3.1

2 release files

1.3.0

2 release files

1.2.2

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.0

2 release files

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page