Skip to content

geomotif.motifs.polar

Roses, harmonics and the sunflower: curves written as angle and radius.

Two halves that share a page because they share an idea -- a shape made by letting something oscillate.

The polar half is :class:Rose and its relatives, where the radius is a function of the angle. The harmonic half is :class:Lissajous, :class:Harmonic and :class:Harmonograph, where x and y each oscillate on their own and the shape is what their beat produces. :class:Phyllotaxis belongs to neither and to both: it is a point set rather than a stroke, and it is the one motif in the library that plants ship.

Classes:

Name Description
Rose

The rhodonea r = cos(n/d * theta), with the petal count right.

MaurerRose

A rose walked in whole-degree steps and joined by straight chords.

Lissajous

Two perpendicular oscillations, plotted against each other.

Harmonic

Sums of sines on each axis: :class:Lissajous with more terms.

Pendulum

One swinging weight of a :class:Harmonograph.

Harmonograph

The Victorian drawing machine: swinging pendulums, slowly running down.

Phyllotaxis

The sunflower head: r = c*sqrt(n) at n golden angles.

PolarExpression

Any radius function you like, wrapped as a motif.

Rose dataclass

Rose(n: int = 5, d: int = 1, size: float = 100.0, center: Point = (0.0, 0.0), *, resolution: int | None = None)

Bases: ParametricMotif

The rhodonea r = cos(n/d * theta), with the petal count right.

The petal count is the part everyone gets wrong, because it depends on the parity of the reduced fraction rather than on the numbers as typed. With k = n/d in lowest terms the curve has n petals when n*d is odd and 2*n when it is even, and it closes after d*pi or 2*d*pi respectively. This class works that out and sweeps exactly that far, so no petal is ever traced twice.

d = 1 covers the familiar roses: three petals at n = 3, eight at n = 4. Larger denominators give the tangled many-lobed rhodoneas.

Parameters:

Name Type Description Default
n int

Numerator of the angular frequency.

5
d int

Denominator of the angular frequency. Reduced against n, so Rose(4, 2) and Rose(2, 1) are the same flower.

1
size float

Petal length, measured from the center.

100.0
center (float, float)

Where the petals meet.

(0.0, 0.0)

Methods:

Name Description
petal_count

Return how many petals this rose actually has.

closure

Return the angular sweep after which the curve returns to its start.

petal_count

petal_count() -> int

Return how many petals this rose actually has.

Source code in src/geomotif/motifs/polar.py
def petal_count(self) -> int:
    """Return how many petals this rose actually has."""
    n, d = _reduced(self.n, self.d)
    return n if (n * d) % 2 == 1 else 2 * n

closure

closure() -> float

Return the angular sweep after which the curve returns to its start.

Source code in src/geomotif/motifs/polar.py
def closure(self) -> float:
    """Return the angular sweep after which the curve returns to its start."""
    n, d = _reduced(self.n, self.d)
    return math.pi * d if (n * d) % 2 == 1 else math.tau * d

MaurerRose dataclass

MaurerRose(n: int = 6, degrees: int = 71, size: float = 100.0, center: Point = (0.0, 0.0))

Bases: PolygonMotif

A rose walked in whole-degree steps and joined by straight chords.

Peter Maurer's construction, and the best return on effort in the whole catalog: take the points of a rose at 0, degrees, 2*degrees and so on, join them with straight lines, and the chords weave a filigree the underlying curve gives no hint of. Change degrees by one and the whole pattern reorganizes.

This is a :class:~geomotif.PolygonMotif rather than a curve: the vertices are the design, and measuring the chords at even parameters would round off every corner that makes the pattern.

Parameters:

Name Type Description Default
n int

Petal frequency of the underlying rose, r = sin(n * theta).

6
degrees int

Whole degrees per step. Coprime with 360 gives the full 360-chord figure; a common factor closes the walk early on a coarser one.

71
size float

Petal length of the underlying rose.

100.0
center (float, float)

Where the petals meet.

(0.0, 0.0)

Methods:

Name Description
chord_count

Return how many chords the walk takes before it closes.

chord_count

chord_count() -> int

Return how many chords the walk takes before it closes.

Source code in src/geomotif/motifs/polar.py
def chord_count(self) -> int:
    """Return how many chords the walk takes before it closes."""
    return _STEPS_PER_TURN // math.gcd(self.degrees % _STEPS_PER_TURN, _STEPS_PER_TURN)

Lissajous dataclass

Lissajous(a: int = 3, b: int = 2, delta: float = pi / 2.0, width: float = 200.0, height: float = 200.0, center: Point = (0.0, 0.0), *, resolution: int | None = None)

Bases: ParametricMotif

Two perpendicular oscillations, plotted against each other.

What an oscilloscope draws with a signal on each axis, and how frequency ratios were measured before there was anything better: the figure is stable only when the ratio is exactly rational, and it stands still only when the phase is too. a = b degenerates to an ellipse, and to a circle when delta is a quarter turn.

Parameters:

Name Type Description Default
a int

Frequencies on x and y. Whole numbers, because the figure closes only when their ratio is rational.

3
b int

Frequencies on x and y. Whole numbers, because the figure closes only when their ratio is rational.

3
delta float

Phase offset applied to x, in radians.

pi / 2.0
width float

Full extent on each axis.

200.0
height float

Full extent on each axis.

200.0
center (float, float)

Middle of the figure.

(0.0, 0.0)

Harmonic dataclass

Harmonic(x_terms: tuple[tuple[float, float, float], ...] = ((100.0, 1.0, 0.0), (40.0, 5.0, 0.0)), y_terms: tuple[tuple[float, float, float], ...] = ((100.0, 1.0, pi / 2.0), (40.0, 5.0, pi / 2.0)), center: Point = (0.0, 0.0), *, resolution: int | None = None)

Bases: ParametricMotif

Sums of sines on each axis: :class:Lissajous with more terms.

Each term is (amplitude, frequency, phase), and the axes are independent: matching term for term across the two gives a clean rosette, mismatching them gives a knot, and a single fast term against a slow one gives a ribbon with a ripple in it::

Harmonic(
    x_terms=((100.0, 1.0, 0.0),),
    y_terms=((100.0, 3.0, 0.0), (40.0, 17.0, 0.0)),
)

The frequencies are whole numbers, because that is what makes the figure close.

Parameters:

Name Type Description Default
x_terms tuple of (float, float, float)

The sine terms driving each axis. At least one each.

((100.0, 1.0, 0.0), (40.0, 5.0, 0.0))
y_terms tuple of (float, float, float)

The sine terms driving each axis. At least one each.

((100.0, 1.0, 0.0), (40.0, 5.0, 0.0))
center (float, float)

Middle of the figure.

(0.0, 0.0)

Pendulum dataclass

Pendulum(amplitude: float = 100.0, frequency: float = 2.0, phase: float = 0.0, damping: float = 0.006)

One swinging weight of a :class:Harmonograph.

Parameters:

Name Type Description Default
amplitude float

How far it swings at the start.

100.0
frequency float

Radians per unit of time. Two pendulums at almost the same frequency are what makes a harmonograph drift instead of repeat.

2.0
phase float

Where in its swing it is released, in radians.

0.0
damping float

Exponential decay per unit of time. Zero never settles.

0.006

Methods:

Name Description
at

Return this pendulum's displacement at time t.

at

at(t: float) -> float

Return this pendulum's displacement at time t.

Source code in src/geomotif/motifs/polar.py
def at(self, t: float) -> float:
    """Return this pendulum's displacement at time ``t``."""
    decay = math.exp(-self.damping * t)
    return self.amplitude * decay * math.sin(self.frequency * t + self.phase)

Harmonograph dataclass

Harmonograph(x_pendulums: tuple[Pendulum, ...] = (Pendulum(140.0, 2.0, 0.0, 0.006), Pendulum(60.0, 3.0, pi / 4.0, 0.0018)), y_pendulums: tuple[Pendulum, ...] = (Pendulum(140.0, 2.005, pi / 2.0, 0.006), Pendulum(60.0, 4.0, 0.0, 0.0018)), duration: float = 50.0, center: Point = (0.0, 0.0), *, resolution: int | None = None)

Bases: ParametricMotif

The Victorian drawing machine: swinging pendulums, slowly running down.

Two pendulums per axis, each decaying, and a pen where their motions meet. What makes the figure rather than a scribble is detuning: set two frequencies to 2.0 and 2.005 and the loops precess a little on every pass, so the curve fills a band instead of retracing itself. The damping is what closes the spiral inwards and ends the drawing.

Parameters:

Name Type Description Default
x_pendulums tuple of Pendulum

What drives each axis. At least one each; two is the classic machine.

(Pendulum(140.0, 2.0, 0.0, 0.006), Pendulum(60.0, 3.0, pi / 4.0, 0.0018))
y_pendulums tuple of Pendulum

What drives each axis. At least one each; two is the classic machine.

(Pendulum(140.0, 2.0, 0.0, 0.006), Pendulum(60.0, 3.0, pi / 4.0, 0.0018))
duration float

How long to let it swing. Longer means a denser figure, up to the point where the damping has stopped it.

50.0
center (float, float)

Where the pen rests once everything has settled.

(0.0, 0.0)

Phyllotaxis dataclass

Phyllotaxis(count: int = 500, spacing: float = 8.0, angle: float = GOLDEN_ANGLE, center: Point = (0.0, 0.0))

Bases: Motif

The sunflower head: r = c*sqrt(n) at n golden angles.

Vogel's model of how a plant packs seeds, and the best dot art in the library for the least work. The square root keeps the density even from the middle to the rim; the golden angle keeps consecutive seeds from ever lining up, which is why the spiral arms you see are an illusion of the packing rather than anything the formula mentions. Their count is always a Fibonacci number.

Produces loose points rather than a stroke: joining them in order would draw a line no sunflower has.

Parameters:

Name Type Description Default
count int

Number of seeds.

500
spacing float

Scale factor. The head's radius works out at spacing * sqrt(count).

8.0
angle float

Turn between consecutive seeds, in radians. Defaults to :data:GOLDEN_ANGLE; nudge it a hundredth and the packing falls apart into visible spokes, which is worth trying once.

GOLDEN_ANGLE
center (float, float)

Middle of the head.

(0.0, 0.0)

PolarExpression dataclass

PolarExpression(formula: Callable[[float], float] = _ripple, *, resolution: int | None = None, center: Point = (0.0, 0.0), theta_start: float = 0.0, theta_span: float = tau)

Bases: PolarMotif

Any radius function you like, wrapped as a motif.

The escape hatch for a one-off polar curve that does not deserve a class of its own::

PolarExpression(lambda t: 60 + 20 * math.sin(9 * t))

Subclassing :class:~geomotif.PolarMotif is still the better answer for anything you will use twice -- it gets a name, a docstring, parameters and a registry entry. This is for the other times.

Parameters:

Name Type Description Default
formula callable

Maps an angle in radians to a radius. Called across the sweep only. Named formula rather than radius because radius is the method it is about to become.

_ripple
Notes

The design this builds records the function object in its metadata, so it round-trips within a session but not through a file. Anything that has to survive being written down wants a real registered class.