Where the points land¶
This is the part of geomotif that is not like other geometry libraries, so it is worth ten minutes.
Two different meanings of "evenly spaced"¶
Take a spiral. Its formula is a radius and an angle, both functions of one parameter. The easy way to draw it is to step that parameter evenly and connect what comes out — and every point you place is then evenly spaced in the parameter, which on a spiral means bunched up where the curve is tight and stretched out where it is wide.
That is fine for a picture. It is wrong for anything where the distance between the points is the thing you care about: dot art, bead layout, plotter stipple, object placement, particle seeding, a motion path with a constant speed along it.
geomotif's default is the other meaning. generate(n) gives you n points
whose real x,y distance from their neighbours is equal:
You can ask for the parametric behavior when you want it — it is occasionally a nice design effect — but you have to ask:
How it works¶
There is no closed form for the arc length of most interesting curves, and for plenty of motifs there is no formula at all — an L-system is a walk, a Voronoi diagram is the output of an algorithm. So the engine does not integrate anything. It:
- Densifies the shape into a polyline fine enough that the error of
treating each piece as straight is below what you could plot
(
densify). - Builds a cumulative-length table over that polyline
(
ArcTable). - Inverts it: given a target distance, binary-searches the table and interpolates within the segment it lands in.
Because step 2 only ever sees a list of points, every motif gets this for free — including your own, and including motifs whose geometry arrived from an algorithm rather than an equation.
Step 3 is where the time goes, and resampling asks for a whole run of distances
at once — in increasing order, because that is what walking along a curve means.
ArcTable.points_at takes the run
rather than one distance at a time and walks the table once between them all,
which is where most of the cost of a large resample went. Order is exploited but
never assumed: a distance that goes backwards seeks again, so the answers are
identical to calling point_at in a loop, to the last bit.
The one place the two measures visibly differ
Spacing is measured along the curve. When the 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 comes out noticeably shorter than its along-curve length, which is a fact about circles rather than about the implementation.
Fixed step: let the count fall out¶
For plotter and dot-placement work you usually do not have a point budget; you have a gap. Say the gap and let the geometry decide how many points that takes:
design = spiral.generate(step=5.0) # a point every 5 units of real distance
len(design) # however many that came to
count= and step= are mutually exclusive, and step is applied
independently to every stroke in a design.
Spacing curves¶
Equal spacing is the default, not the only option. A spacing curve maps [0, 1] → [0, 1] and reshapes how the arc length is consumed — dense at one end, dense at both ends, whatever the curve says:
from geomotif import PowerSpacing, ExponentialSpacing, SmoothstepSpacing
spiral.generate(200, spacing=PowerSpacing(2.5))
spiral.generate(200, spacing=ExponentialSpacing(mode="out", strength=6))
spiral.generate(200, spacing=SmoothstepSpacing())
| Curve | Character |
|---|---|
LinearSpacing() |
equal spacing — the default |
PowerSpacing(exponent, mode) |
general "by how much" control; 1 is equal |
QuadraticSpacing(mode) |
classic t² easing |
CubicSpacing(mode) |
classic t³ easing |
SineSpacing(mode) |
a 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 a plain callable |
CompositeSpacing(*curves) |
chain eases left to right |
TableSpacing(points) |
draw the curve by hand from control points |
Most take mode="in" (spacing gradually increases), "out" (gradually
decreases) or "in_out". The modal machinery lives once in the base class, so
a curve you write gets all three modes from implementing ease(t) alone:
from geomotif import SpacingCurve
class Stepped(SpacingCurve):
"""Four discrete bands rather than a continuous ramp."""
def ease(self, t: float) -> float:
return round(t * 4) / 4
A plain callable works too — anything taking a float and returning a float is
accepted wherever a curve is, and
coerce_spacing is what wraps it.
Designs with more than one stroke¶
A tiling has hundreds of strokes; a Cassini oval has two. count is a total,
and distribute= decides how it is split:
| Mode | Split | Use it when |
|---|---|---|
"length" (default) |
proportional to each stroke's arc length | you want uniform visual density |
"even" |
count // len(paths) on each |
you want uniform per-stroke detail |
"per_path" |
count on every stroke |
the strokes are meant to match each other |
tiling.generate(5000) # even density over the whole figure
tiling.generate(5000, distribute="even") # every cell gets the same budget
Loose points — the ones a design carries outside any stroke, like the seed head of a phyllotaxis or the output of a chaos game — are passed through untouched. They are already exactly the points the motif meant, and there is no curve to redistribute them along.
Native resolution¶
build() gives a motif at whatever resolution it thinks it needs;
generate() resamples that. The distinction matters more than it looks:
design = motif.build() # the shape, at its own idea of enough detail
design = motif.generate(200) # 200 points, arc-length placed
For a curve, native resolution is a smooth-enough polyline — a few hundred
samples, or more if it winds a lot. For a polygon it is the corners and nothing
else, which is the whole reason
PolygonMotif exists separately from
ParametricMotif: sampling a pentagon at 512
evenly spaced parameters rounds all five corners off, and listing them costs
five points. Concretely, RegularPolygon(sides=5).build() returns exactly
those five corners; RegularPolygon(sides=5).generate(512) would return 512
points placed by arc length that miss the corners entirely. That is when the
distinction matters: keep the corners when the corners are the point.
If you are exporting for a plotter, build() is usually what you want. If you
are placing objects, generate() is.