Designs, paths and transforms¶
The data model¶
There are four types, and three of them are trivial.
Point is tuple[float, float]. Not a class — a
tuple, so it unpacks, compares, hashes and goes straight into anything that
expects a pair of numbers.
Bounds is an axis-aligned rectangle with
width, height, center, union(), padded() and in.
Path is one stroke: a tuple of points, plus
closed. It knows its own length and bounds.
Design is the universal result — some strokes,
some loose points, and metadata:
design.paths # tuple[Path, ...] -- the strokes
design.points # tuple[Point, ...] -- points that carry no stroke
design.meta # the motif name and its resolved parameters
The split between paths and points is the difference between a line and a
scatter, and it survives all the way to the exporters: a path becomes a <path>
or a DXF POLYLINE, a loose point becomes a <circle> or a DXF POINT. Dot
art and stipple are loose points; a plotter drawing is paths.
A design iterates as a flat stream of coordinates, so it drops into anything that just wants numbers:
for x, y in design:
place_object(x, y)
len(design) # total point count, strokes and loose points together
design.bounds # over everything
design + other # overlay
Everything is immutable. Design, Path and Bounds are all frozen slotted
dataclasses, every operation returns a new value, and NaN and infinity are
rejected at construction rather than propagating silently into an exported
file.
The operations on a design¶
design.fit(800, 600, padding=20) # scale and center onto a canvas
design.flipped_y() # y-down (screen/SVG) coordinates
design.transformed(matrix) # apply an affine
design.resampled(400) # the same engine generate() uses
fit scales uniformly — a design is never distorted — and centers it in
whatever axis has slack. So the axis that limits the scale fills the canvas
and the other one is centered inside it, which is what you want and is worth
knowing before you assert on the result.
Affine transforms¶
Affine is the usual 2×3 matrix, with named
constructors and @ for composition:
import math
from geomotif import Affine
m = Affine.rotate(math.pi / 6) @ Affine.scale(2.0)
design.transformed(m)
@ reads right to left, the way function composition does:
(m @ n)(p) == m(n(p)), so the right-hand transform applies first. Every
Affine is invertible with .inverse() unless it is degenerate, in which case
it says so.
Turning one shape into a pattern¶
The composite operators in geomotif.core.transform
are where a motif becomes a pattern. They take a design and return a design, so
they compose with each other and with anything a motif built:
from geomotif import clip_to, jitter, layer, mirror_axis, radial_repeat, snap, symmetry_group, tile
petal = my_motif.build()
cell = my_cell.build()
rosette = radial_repeat(petal, 12) # the mandala workhorse
lattice = tile(cell, 8, 8, dx=20, dy=20, stagger=0.5) # brickwork if stagger != 0
mirrored = mirror_axis(design, math.pi / 4)
group = symmetry_group(design, "D6") # the dihedral groups by name
loose = jitter(design, 0.5, seed=7) # reproducible irregularity
aligned = snap(design, 0.5) # every point onto a half-unit grid
inside = clip_to(design, bounds) # segment-level, not point-level
stack = layer(background, middle, foreground)
Three of those have a detail worth calling out.
jitter takes a seed, and the same seed always reproduces the same result —
the RNG lives only inside the call, so a reproducible irregularity does not
depend on the global random state. The seed is not recorded in meta; keep
hold of it yourself if you want to regenerate the same points.
snap is jitter's opposite number and the rounding you would otherwise have
to do per file. It takes any grid rather than a number of decimal places, and
drops the points a coarse grid stacked on top of each other, because those are
zero-length segments the plotter would spend time on for no ink. It is also the
one operator here that gives something up: grid alignment costs you a little of
the exact arc-length spacing. See Snapping to a grid.
clip_to clips segments, not points. Dropping the points that fall outside
a rectangle leaves a stroke that jumps the gap; clipping the segments cuts each
one at the boundary and splits the stroke there, which is what a plotter needs
and what a viewer expects to see.
Composing motifs out of motifs¶
geomotif.compose holds motifs whose parameter is another
motif:
from geomotif.compose import Kaleidoscope, LayeredRings, Mandala, Ring, Snowflake, SpokePattern
from geomotif.motifs import Circle, StarPolygon
mandala = Mandala(
rings=(
Ring(unit=StarPolygon(points=7, step=3, radius=14.0), count=12, radius=80.0),
Ring(unit=Circle(radius=10.0), count=24, radius=120.0),
)
)
The unit is anything with a build() method, which means a composed figure can
itself be the unit of another composition. It also means the composers accept
your motifs on exactly the same terms as the builtin ones.
Metadata and reproducibility¶
Design.meta carries the motif's name and its resolved parameters — including
any random seed that is itself a parameter, the way PoissonDiscPoints.seed
is. That is what makes a design self-describing: the gallery labels its images
from it, to_spec writes a recipe from it, and load_design reads it back on
a machine that does not have the motif that produced it. A seed that belongs
to an operator rather than a motif is different: jitter's seed is not
recorded in meta, so regenerating the same jittered points means keeping
hold of that seed yourself.
meta is a read-only mapping, and overlaying two designs merges it
right-biased. A composed design no longer describes a single motif, so the
composers set their own meta on the result rather than trusting that merge.