Changelog¶
All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
1.3.1 — 2026-08-04¶
The 1.3.0 catalog built cleanly only on Python 3.13+ while still declaring
3.12 support. CPython dedents class docstrings at compile time starting in
3.13, so reading __doc__ directly produced a different catalog.json on
3.12 and the committed-artifact freshness test failed. The catalog now
normalizes with inspect.cleandoc, so the committed artifacts are
byte-identical on every supported interpreter.
Fixed¶
- The committed
catalog.json(and the drift-check test that guards it) now passes on Python 3.12, where raw class docstrings keep their source indentation instead of being dedented at compile time like on 3.13+.registry.describenormalizes withinspect.cleandoc, so generated docs are byte-identical across all supported versions.
1.3.0 — 2026-08-04¶
The explore overhaul ships: a web explorer for every motif in the catalog,
keyframes growing geomotif.animate into a general multi-parameter
primitive that spec files and the CLI both speak, and parameter ranges
moving from guessed to declared so every consumer reads the same bound.
Added¶
-
catalog.json, the registry serialized for the web explorer.tools/gendocs.pynow emitsdocs/assets/explore/catalog.jsonalongsidecatalog.md: every registered motif's name, family, summary, full doc, availability, registered example, and parameters — including theRangebounds and theLiteralchoices a dropdown would offer. The web explorer (landing in 1.3.0) reads this once at load time and builds its controls from it, so the page needs no server and no Python. Committed likecatalog.md, with a matching freshness test intest_gendocs.pyand amake explore-catalogtarget; the existingmake docs-checkcatches drift because it already watchesdocs/assets. -
Range, a min/max/step helper for motif parameters. A newgeomotif.core.range.Rangemaps cleanly ontodataclasses.field'smetadata=argument (the same routehelptext already takes), so a parameter declares its bound next to its default in one line:n: int = field(default=5, metadata=Range(1, 50, step=1)).Rangeis a frozenMapping, so the keys are additive — a field that carries one carries bothhelpandRange, and a parameter without aRangestill falls back to the heuristic. Exported fromgeomotifandgeomotif.core. ParamInfo.min,.max,.step.registry.describe()now reports the declared range alongside the default and the help text, so the CLI, the docs, the gallery and the web explorer all read the same bound. The three areNonewhen a motif has not declared a bound, in which case a consumer falls back to its own heuristic rather than guessing zero.-
Curated ranges for the most-used motifs. The polar, harmonic, spiral, fractal, curve and primitive families now declare
Rangeon every numeric parameter a slider can move — petal counts, depths, scales, radii, side counts and point counts. Motifs without a natural bound keep the heuristic, so the catalog is usable even before every motif is curated. -
keyframes, a multi-parameter animation primitive ingeomotif.animate. Wheresweepvaries one parameter across a list of values,keyframesvaries several at once, each across its own time points in[0, 1]:keyframes(Rose(), {"n": [(0.0, 3), (1.0, 9)]}, frames=48). Numeric parameters interpolate component-wise with an easing curve fromgeomotif.core.spacing(linear,quadratic,cubic,sinusoidal,exponential,circular, with aname:modesuffix for ease-out variants);bool,Literalandstrparameters step at the next keyframe; integer parameters round and deduplicate, so adjacent frames that round to the same value share one builtDesign. Per-track easing overrides the global one. An eased value a motif rejects falls back to the last frame that built, with akeyframes_fallbacknote in its metadata. A smallcompose(motions, frames)helper chainsdraw_on_overlay/spin_overlaypost-passes onto a run of frames, so a Hilbert curve can draw itself on while itsdepthsweeps. This is the primitive the 1.3.0 web explorer's animation editor is built on, so an animation shared from the browser reproduces in the CLI byte-for-byte. animationkey in spec files.io/spec.pylearns an optional top-levelanimationkey carrying the recipe for a moving picture:{"type": "keyframes", "tracks": {...}, "frames": N, "fps": X, "hold": K, "easing": "...", "overlay": [...]}-- the same JSON the CLI's--animationflag reads and the web explorer encodes into a share URL.to_spectakes an optionalanimation=mapping;from_specignores the key when building the still motif, so an old spec keeps loading unchanged.geomotif render --animation spec.json. The new flag reads a full spec (motif + params +animation), runs thekeyframesprimitive, applies anyoverlaypost-passes, and writes a.gifthrough the existing pure-stdlib writer. The recipe'sfpsis authoritative, so a shared animation plays at the same speed on the command line as in the browser. Mutually exclusive with a positional motif name and--spec.-
The explorer is staged into the deployed site. The Pages build now builds the geomotif wheel and copies it, alongside the SPA sources, into
site/explore/, so the explorer served at/geomotif/explore/loads the matching-pinned wheel —app.jsderivesWHEEL_URLfromcatalog.geomotif, so the build and the runtime stay in lockstep automatically. A newmake explore-stagetarget mirrors CI locally: it builds the wheel, runs the docs build, and stages the SPA + wheel intosite/explore/. -
An Explore landing page in the docs nav.
docs/explore.mdis a short page that explains what the web explorer is — still and animation modes, live command line, share URL, SVG/PNG/spec/GIF export, zoom/pan, theme toggles — and points into the SPA with a call-to-action button. The Material nav gains an Explore entry beside Contributing, so the boundary between the written docs and the web app is explicit and the explorer is one click from the home page.
Changed¶
geomotif exploresweeps a declared range when there is one. A parameter with aRangeis now sampled across that range (in whole steps for an integer with astep, linearly for a float) rather than around its default with the_SPREADheuristic. The heuristic stays as the fallback for parameters without a declared range, so pages that worked before keep working.
1.2.2 — 2026-08-02¶
The documentation release: a pass over the guides against the code, fixing what was wrong and what read wrong. No behavior changes; the library is unchanged.
Fixed¶
- The 'centerd' typo. The American-spelling test catches the British forms it bans but not the plain misspelling 'centerd', which had settled into five module docstrings and four test names. All ten now read 'centered'.
- Reproducibility, restated. The 'Metadata and reproducibility' section
claimed any random seed lands in
meta; only a motif-parameter seed does (the wayPoissonDiscPoints.seedis). An operator's seed --jitter's -- is not recorded, so regenerating the same jittered points means keeping hold of it yourself. - The CLI guide's
--easeexample.--ease power:exp:out:6does not parse;--ease exponential:out:6does, and is what the example now says. - The exporter guide's quick reference is a table first, with the longer
notes below it. The front page lists PNG and JPEG among the exports, the
extending guide reaches for
ParametricMotiforPolygonMotiffirst, and both now say 'ten formats' rather than five. - The animation guide's Raster note names the fields (
width,height,pixels,palette,mode) and points atrasterize_rgbafor the truecolor writers, rather than thergba()method the code does not have. - The points guide gets a concrete
RegularPolygon(sides=5)example, the plotting guide recommendssave_pngas the no-matplotlib still, and the front-page example showsSpiralBetweenwith keyword arguments, matching the README.
1.2.1 — 2026-08-02¶
The JPEG interop fix: the stills the encoder writes now render identically in libjpeg and Pillow, closing the gap the 1.2.0 entry warned about.
Fixed¶
- JPEG AC coefficients were written in the wrong zig-zag order. The
_ZIGZAGtable held ITU-T T.81 Annex A's standard mapping (scan position → row-major index), but both the encoder (jpeg.py) and the suite's decoder (tests/readback.py) looked it up backwards (row-major index → scan position). Because both sides shared the mistake, the round-trip passed while external decoders placed every AC coefficient in the wrong frequency. The table is now stored inverted, matching what both call sites actually need. - JPEG quantization tables were written in natural rather than scan
order. The DQT payload must list its 64 steps in zig-zag scan order
(Annex A), but
_dqt_tableemitted them row-major and_parse_quantread them back the same way. External decoders therefore dequantized with the step for the wrong frequency. Both now use scan order, so files are standard and the interop check in verification 2 passes. - The "honest caveat" paragraph is gone from the
geomotif.io.jpegmodule docstring, and the export guide's "A word about JPEG" note no longer steers readers away to PNG/GIF for exactness. A JPEG opened in another tool now matches what the suite's own reader produces, to within IDCT rounding.
1.2.0 — 2026-08-01¶
The raster overhaul: stills join the zero-dependency picture pipeline, and the one hard-coded GIF becomes a full set of knobs.
Added¶
- PNG stills, pure standard library (
geomotif.io.png—save_png,to_png) — a design renders once, antialiased or hard-edged, into a full-color frame and is encoded withzliband per-chunk CRCs. Truecolor RGB by default, RGBA on request, or indexed through the same median-cut quantizer the GIF uses;--compression 0-9tunes the deflate level. Renders fromrender --out rose.pngwith no matplotlib and no extra install. - JPEG stills, pure standard library (
geomotif.io.jpeg—save_jpeg,to_jpeg) — the same RGBA frame encoded as baseline JPEG: RGB to YCbCr with 4:2:0 chroma subsampling, an 8x8 DCT, quality-scaled quantization, and Huffman coding against the reference tables.--quality 0-100scales the quantization (default 85)..jpgand.jpegrender fromrender --outwith no matplotlib and no extra install. As of 1.2.1 the files it writes render identically in libjpeg and Pillow (see the[1.2.1]entry). - Stills vs animation — PNG and JPEG are single stills of the finished
design; the animation flags are ignored rather than refused, so
render rose --motion spin --out rose.pngdegrades gracefully to a still. - Canvas and styling flags for raster output —
--canvas WxH(the pixel canvas, independent of the geometry-fitting--fit),--stroke-width,--dot-radius,--ink,--background,--padding, and--loopfor GIF. - Antialiasing and dithering —
--antialiassupersamples edges and blends them by coverage;--aa-level Nand--dither/--no-dithercontrol how the result is squeezed into GIF's 256-color budget. - Transparent backgrounds —
--transparentleaves a.pngor.gif's background empty so the drawing sits over whatever the page shows. A PNG is written truecolor-with-alpha with the background at alpha 0 and antialiased edges as straight alpha; a GIF flags index 0 as its transparent color in every frame. A.jpghas no alpha, so the flag is ignored there the way an animation flag is for a still.
Changed¶
colors_inis the name;colours_inis deprecated. The British spelling shipped in 1.1.0 keeps working and warns it will go away in a major.- American spelling is the house style, documented in
docs/style-guide.mdand enforced bytests/test_spelling.py— including the docs reference page being renamed tocatalog.md.
1.1.0 — 2026-07-31¶
The stretch release: color and layers, symmetric point sets, a faster arc-length inversion, animation and a GIF writer, plotter output, an explorable gallery, and snapping to a grid. All of it additive — a design with no styles writes the file it always did, and every motif in the catalog builds the geometry it built before.
Two of them ended somewhere other than where they set out. The numpy fast path was written, measured, and deleted in favour of a pure-Python one that turned out to be faster (see Changed); and the symmetric point sets ship marked experimental, because what "evenly spaced" should mean when the symmetry group makes it impossible is still an open question.
Added¶
- color and layers (
geomotif.core.style) —Style,styled,styles_of,point_styles_of,layer_namesandby_layer. A style is attached per stroke and per loose point, and rides inDesign.metarather than inPath, because none of it changes the maths. Styles survive every transform, are laid end to end by+solayer(red, blue)keeps both, are dropped alongside a stroke that resampling drops, and are carried onto every fragmentclip_tocuts a stroke into. - Layered output — SVG writes each layer as the group Inkscape and
vpyperead, plus per-elementstroke,stroke-widthandfillwherever a style differs from the document's own; DXF writes real DXF layers and the seven colors its indexed palette can name; matplotlib draws a styled stroke in its own color and width. A design carrying no styles writes exactly the file it wrote before. - Styles round-trip through
save_design/load_designand through a spec, written beside the parameters rather than among them. select_styles,PATH_STYLE_KEYandPOINT_STYLE_KEY(geomotif.core.types) — the extension-facing half. An operator that drops, splits or reorders strokes handsselect_stylesthe source index of each one it kept, and the styles follow the geometry rather than shifting onto the wrong stroke. Operators built out of+get this for free. See extending.SymmetricPointSet(geomotif.motifs.symmetry, familysymmetry) — the first motif that is solved for rather than evaluated. Points are laid out in the orbits of a cyclic or dihedral group and then relaxed toward equal nearest-neighbour spacing, which is what makes the awkward cases possible: fifteen points with five-fold mirror symmetry needs one ten-point orbit and one five-point orbit sitting on the mirror lines, and comes out exactly evenly spaced. A count the group cannot arrange is refused with the nearest two it can. Symmetry is preserved by construction — only one representative per orbit moves — so no seed is involved and the result is exactly reproducible. Four rules for joining the points up, including"equal-distance", which draws the edges the relaxation was equalizing. Marked experimental; see the API policy.ArcTable.points_at/points_at_fractions— batch inverse lookup. Resampling asks for a run of distances in increasing order, so the run walks the cumulative table once between them all instead of binary-searching the whole of it per point. Order is exploited but never assumed: a distance that goes backwards seeks again, so the answers are identical topoint_atin a loop, bit for bit.- Animation (
geomotif.animate) —draw_onreveals a design progressively by arc length, so the pen moves at a constant speed rather than racing through the sparse parts of the geometry;spinturns it about a point;sweeprebuilds a motif once per value of one of its parameters. Each returns a plain tuple of designs, so frames compose with everything else. A partly drawn closed path comes back open, because half a square is not a square. - Animated GIF (
geomotif.io.gif) —to_gifandsave_gif, LZW and all, in pure standard library. Every frame is drawn against the same world rectangle and the same color table, so a growing drawing stays put and a two-pen design animates in two colors. Underneath it,geomotif.io.rasterturns a design into an indexed bitmap:rasterize, theRasterit returns, andcolors_infor the palette a set of designs needs. All three are usable on their own. The GIF writer understands the same color names the DXF writer does, so a styled design that exports to one exports to the other. geomotif render NAME --out x.gifwith--motion draw-on|spin,--frames,--holdand--fps.--fitdoubles as the pixel canvas, which is 480×480 without it.draw-onappends a quarter of--framesagain as a hold on the finished drawing, so a loop pauses rather than restarting the instant it arrives;--hold Nsets that pause yourself, and--hold 0turns it off.ArcTable.segment— the part of a polyline between two distances, with the ends interpolated and every vertex between them kept.- Plotter output (
geomotif.io.plotter) —to_plotter_svgandsave_plotter_svgwrite a design at a named paper size in real millimeters (width="210mm", notwidth="210"), withPAPER,page_sizeandon_pagebehind them.optimizejoins strokes whose ends meet and then orders them so the pen travels as little as possible between them, andpen_up_distancemeasures the difference: a 6×6 Truchet tiling goes from 72 strokes and 2742 units of pen-up travel to 13 and 533. Neither pass ever crosses a layer, since strokes on different layers are drawn by different pens, and neither changes the ink. to_vpype— hands a design to vpype directly, one vpype layer per style layer, named and page-sized.vpypeis not a dependency of the library; the import is guarded and says how to install it. It is a dependency of the tests: theplottergroup and a CI job of its own exist so that the comparison ofoptimizeagainstvpype linemerge linesort, and the read-back of geomotif's own plotter SVG throughvpype's reader, actually run rather than skipping quietly.to_svg(..., units="mm")— a physical unit for the document'swidthandheight, fromUNITS, with theviewBoxleft in plain numbers.geomotif render NAME --out x.svg --paper a4 [--landscape] [--margin MM] [--optimize].- An explorable gallery (
geomotif.explore,geomotif explore) — one self-contained HTML page with a slider for every parameter a slider can move. Every frame is rendered ahead of time by geomotif's own SVG writer and embedded in the document, so the page needs no server, no build step and no JavaScript library, and works from afile://URL. One parameter moves at a time: each slider sweeps its own with the others left at the motif's example values, because rendering every combination would be a combinatorial explosion. A value the motif refuses is dropped from the sweep rather than reported, and a parameter with no single axis to drag along — a point, a set of coordinates, another motif — is listed on the page as held still.to_html,save_html,sweeps_forandSweepare the Python side of it;--steps,--sizeand--samplesset how many values a slider offers, how big each frame is, and how densely it is drawn. - Snapping to a grid —
snap(geomotif.core.transform) andDesign.snapped, which round the design rather than each file as it is written, so every writer, the plot and the gallery agree on the numbers. Three things a writer'sprecision=cannot do: any grid rather than only powers of ten (snapped(0.5),snapped(5.0)); a choice of rounding rule (half-even, the default and whatprecision=has always done, thenhalf-up,floor,ceil,trunc, listed inSNAP_MODES); and somewhere to put the points a coarse grid lands on top of each other. Those are zero-length segments — no ink, and a pen-down/pen-up the plotter spends time on regardless — so they are dropped by default, along with any stroke left with fewer than two points, with each surviving stroke's style carried across.drop_duplicates=Falsekeeps the point count exactly as it was, for a caller feeding a fixed-size buffer or a per-point parallel array.half-uprounds away from zero rather than toward +∞, so a symmetric design does not come off the grid asymmetric. geomotif render NAME --snap STEP [--snap-mode MODE] [--keep-duplicates], applied after--fit. The writers that place a design themselves —.svg,--paperincluded,.gifand the matplotlib formats — rescale the grid away as they write; the coordinate formats and.dxfkeep it exactly.- New at the top level:
Style,styled,styles_of,point_styles_of,layer_names,by_layer,snap,SNAP_MODES,SnapMode,to_gif,save_gif,to_plotter_svgandsave_plotter_svg. Import paths are a 1.x promise, so they are listed rather than left to be discovered.
Changed¶
- Resampling a large design is about 1.4x faster (200k points off a 100k-vertex polyline: 355ms to 248ms), with byte-identical output.
A numpy fast path was written and measured first, per the original plan, and
then deleted: with Point being a tuple of Python floats, np.asarray on a
200k-point design costs more (44ms) than the entire pure-Python loop it was
meant to replace, and numpy lost on transforms, bounds and length outright.
It won only on the inverse lookup — and the ordered walk that shipped instead
beats it there too, 67ms against 104ms, with no dependency. The core stays
zero-dependency because that turned out to be the faster answer as well as
the simpler one.
- The DXF layer table now lists the layers a design actually uses rather than
the single layer= argument. Structure-preserving, and a file that parsed
still parses, but it is a change to what is emitted.
- The API policy now defines what experimental means and lists what
carries the label, and its list of public modules gains geomotif.animate
and geomotif.explore.
- save_points and save_design now document what a negative precision
actually does — round to tens, hundreds and so on, rather than merely
"write whole integers" — and it is pinned by a test rather than left as an
accident of round. The behavior is unchanged.
- to_svg refuses a units string that is not one, rather than writing it
into the document's width and height attributes unquoted.
- rasterize refuses a negative padding, which used to return a blank
raster, matching what Design.fit has always done.
- SymmetricPointSet validates connect when it is constructed rather than
when edges() is called, and takes a center given as any pair of numbers.
Fixed¶
Found in an audit of the above before release; none of it ever shipped.
to_plotter_svgandsave_plotter_svgignoredmarginentirely. The design was placed on the page and then fitted to the page a second time by the SVG writer, which scaled it back out to the paper edge — so every margin produced the same file, running to the very edge of a sheet most plotters cannot reach.to_gif(loop=1)looped forever. The Netscape block counts repeats after the first play, so the count that would mean "once" is the one that means "forever"; playing once is the absence of the block.- The rasterizer scaled a design onto
widthpixels where awidth-pixel row addresses0..width-1, so the far edges landed one past the end and were dropped. Invisible at the default padding, and half the picture atpadding=0. SymmetricPointSetcollapsed for a few counts under a dihedral group —D4with 13, 17 or 29 points,D5with 16 or 41, and others. A ring orbit seeded onto a mirror line meets its own reflection, which the relaxation scores as every neighbour perfectly evenly spaced, and settles into. Swept overC2–C8andD2–D8at every count they accept: fifteen collapsed, none does now, and the number of counts that come out exactly evenly spaced rises from 255 to 287.draw_onnever drew a stroke of zero length, so the last frame was not quite the finished drawing and a design of nothing but degenerate strokes animated as nothing at all.snapdropped a duplicate loose point only when it happened to be adjacent in the tuple. Loose points are a set with no walk through them, so the result depended on an ordering that carries no information.plot_designtested a stroke width for truthiness, soStyle(width=0.0)silently became the figure's default.- The explore page printed
--clip Falseunder its sliders, which argparse rejects; a boolean parameter's flag is--clipor--no-clip. save_htmltook(path, names)where every other writer takes(subject, path). Corrected before the signature became a promise.
1.0.0 — 2026-07-27¶
The first release, and the whole of the rework from spiralgen in one entry.
Why 1.0.0 rather than 0.1.0. The library is complete against what it set out to be: the arc-length engine generalized from one curve to every polyline, a data model, eight motif base classes, 146 motifs across 18 families, a transform layer, five export formats plus a spec format, a command line, third-party plugins, and a conformance suite that holds all of it — builtin and third-party alike — to one contract. There is nothing here waiting to be finished before the public API can be relied on, and pretending otherwise with a leading zero would only make the version number less informative. What 1.0.0 promises is written down in the API policy.
Added¶
Design,Path,Bounds(geomotif.core.types) — the universal result type: stroked polylines plus loose points plus reproducibility metadata. Immutable throughout, with+for overlay,fit()for canvas normalization andflipped_y()for y-down coordinate spaces. NaN and infinity are rejected at construction rather than propagating silently into exported output.Motif/SupportsBuild(geomotif.core.motif) — the extension contract. Implementbuild()and inheritgenerate(), which resamples to a point count or a fixed step distance. Inheritance is optional: anything with abuild() -> Designmethod is accepted everywhere a motif is.- Arc-length engine (
geomotif.core.sampling) —ArcTable,densify,resample_pathandresample, generalized from the old spiral generator to work on any polyline. Every motif therefore gets arc-length placement and the whole spacing-curve family for free, including motifs with no closed-form parametrization. Adds fixed-stepplacement for plotter output,by="parameter"for the old parametric behavior, anddistribute=to spread a point budget across a multi-path design. - Transform layer (
geomotif.core.transform) —Affine(composable with@, invertible) plus the composite operatorsradial_repeat,tile,mirror_axis,symmetry_group,jitter,layer,fit_to,clip_toandoffset_path. - Registry (
geomotif.core.registry) —register,names,families,get,createanddescribe, with parameter introspection derived from dataclass fields and lazy third-party discovery via thegeomotif.motifsentry-point group. - Motif base classes (
geomotif.bases) — the extensibility story, and most of the catalog's implementation. Pick the base that matches how your design is defined and write the one method it asks for:ParametricMotif(position(u)),PolarMotif(radius(theta)),MultiCurveMotif(several strands at once),LSystemMotif(an axiom, rewrite rules and a turn angle, drawn with a turtle that branches),SegmentMotif(nodes and edges, optionally chained into polylines) and the two tiling bases,LatticeTiling(a cell repeated on basis vectors and clipped to a region) andSubstitutionTiling(seed tiles subdivided to a depth, generic over your own tile type). PolygonMotif(geomotif.bases) — the base for shapes defined by their corners rather than by a formula. Kept separate fromParametricMotifbecause sampling a polygon at evenly spaced parameters rounds its corners off unless a sample happens to land on each one; a pentagon here costs five points rather than five hundred.outlines()is plural so that a figure made of several loops — the star polygon{6/2}is two overlaid triangles — comes back as several strokes rather than one path with an invented edge between them.PolarMotif.with_turns()— the sweep said in revolutions rather than radians, which is how a wound curve is actually described:LogarithmicSpiral(b=0.2).with_turns(5, clockwise=True).- The spiral family (
geomotif.motifs.spirals) —ArchimedeanSpiral,LogarithmicSpiral,GoldenSpiral,FibonacciSpiral,FermatSpiral,HyperbolicSpiral,Lituus,TheodorusSpiral,EulerSpiral,CircleInvolute, joiningSpiralBetween. The six that are polar functions of theta share aSpiralBaseand are a single line of maths each; the other four are not polar functions at all and say so by using a different base.EulerSpiralbrings a dependency-free Fresnel integral, evaluated by power series to machine precision within the range the curve is actually drawn over. - The primitives (
geomotif.motifs.primitives) —Circle,Ellipse,Arc,Sector,Line,Rectangle,RoundedRectangle,RegularPolygon,StarPolygon,Star,Superellipse,Squircle,ReuleauxPolygon,Egg,PointGridandPoissonDiscPoints. The point fields seed a privaterandom.Random, so a design is reproducible from its metadata and building one never disturbs the global random stream. - The named curves (
geomotif.motifs.curves) —Heart(the valentine and the cardioid form),Cardioid,Lemniscate,LemniscateOfGerono,CassiniOval,Limacon,Butterfly,FishCurve,BowCurve,Astroid,Deltoid,Nephroid,Folium,Cochleoid,Cycloid,Trochoid,WitchandCornoid. Two conventions hold across the module:sizeis the curve's largest extent, so curves composed at the samesizecome out the same size, andcenteris the curve's own origin — a cardioid's cusp, a lemniscate's crossing point — rather than the middle of its bounding box.CassiniOvalhandles its topology change explicitly: below the focal separation it returns two strokes, one per lobe, and it refuses the degenerate case in favour ofLemniscate, which draws it in one. - The roulettes (
geomotif.motifs.roulettes) —Hypotrochoid,Epitrochoid,Hypocycloid,Epicycloid,Spirograph(ring teeth, wheel teeth and which hole the pen goes in) andEpicycles, which stacks any number of(radius, frequency, phase)arms and plots the tip. Two arms give every trochoid, several dozen give a Fourier series. The ring and wheel radii are whole numbers because the ratio has to be rational for the curve to close at all; the classes work out how many revolutions that takes rather than making you say. - Roses, harmonics and the sunflower (
geomotif.motifs.polar) —Rosewith the petal count actually right (npetals whenn*dis odd and2*nwhen it is even, swept exactly to closure so no petal is traced twice),MaurerRose,Lissajous,Harmonic,Harmonographwith itsPendulumvalue type,Phyllotaxis/VogelSpiral, andPolarExpressionfor a radius function that does not deserve a class.GOLDEN_ANGLEis exported alongside them. - The fractals (
geomotif.motifs.fractals) — reached three ways, because the difference is worth seeing. Sixteen are an axiom, a rewrite rule and a turn angle drawn with a turtle:KochCurve,KochSnowflake,KochAntisnowflake,MinkowskiSausage,MinkowskiIsland,SierpinskiTriangle,SierpinskiArrowhead,DragonCurve,TwinDragon,Terdragon,LevyCCurve,HilbertCurve,MooreCurve,PeanoCurve,GosperCurveandVicsekFractal. Five place smaller copies of themselves rather than walk a path, which is a statement about squares and circles instead of about a stroke, so they are built directly:SierpinskiCarpet,CantorSet,PythagorasTree(Bosman's squares, whose children's areas add up to their parent's at every lean — the theorem, drawn),HTreeandApollonianGasket, generated through Descartes' circle theorem so the curvatures of the default packing come out integral. Two arrive by chaos game as loose points:IFSAttractor, with itsIFSMapvalue type, andBarnsleyFern. Both seed a private generator and normalize their cloud to thesizeasked for, since an attractor's own coordinates are an artefact of whichever numbers the maps happen to contain. - Graph and number art (
geomotif.motifs.graphs) —CompleteGraph,CyclicGraph(the circulant, so several step sizes overlay into a rosette),BipartiteGraph,ChordDiagramfor connections that come from data rather than from arithmetic,ModularMultiplication— the times table drawn as chords, whose envelope is a cardioid for the two times table and an epicycloid with one fewer cusp than the factor after that — plusModularAdditionandPrimeChords, which joins two numbers whenever they add up to a prime. - String art (
geomotif.motifs.stringart) — straight threads whose envelope is a curve.StringArtCorner(two arms and the parabola between them),StringArtPolygon(that corner at every corner of a polygon) andStringArtEnvelope, the general "nailion one curve to nailrule(i)on another" engine that the rest of the module and the whole ofgraphsare special cases of.StringArtCircleis an alias forModularMultiplication: circle string art and the times table are the same construction, so it is one class under two names rather than two classes drawing one picture. - Islamic strapwork (
geomotif.motifs.girih) —GirihTiledraws any of the five canonical tiles, generated from one table of interior angles since all five share a side and use only multiples of 36 degrees.TenfoldGirihlays decagons edge to edge on a rhombic lattice, where the gap between them turns out to be exactly a girih bowtie;InterlockingDecagonsapplies Hankin's rule to that tiling and the tiles disappear, leaving interlocked ten-pointed stars. The rule is implemented as simultaneous growth -- two lines stop only when their tips meet, and carry straight on where they merely cross -- because stopping at the first line met cuts the elongated hexagon's long straps down to stubs. PlusRosette,RosetteTilingandHexStarLattice. - Celtic knots (
geomotif.motifs.knots) —Triquetra(three half-circles that join into one strand, which is to say a trefoil),EndlessKnot,CircularCelticKnot,SquareCelticKnotandCelticGrid, the plait with barriers that every knotwork panel is built from. Over-and-under is derived rather than declared: the crossings are found geometrically and two-colored so that each strand alternates and each crossing disagrees with itself, and the under-strand is drawn with a gap in it, which is what a pen plotter can draw. The endless knot's corner joins nest at three corners and swap at the fourth -- nesting all four splits the figure into two separate rings, and the swap is what makes it endless. - Polyhedra (
geomotif.motifs.solids) — the five Platonic solids andTruncatedIcosahedron, plusPolyhedronfor a shape of your own andProjectionfor orthographic, isometric or perspective views with yaw, pitch and roll. Every solid in the catalog is a table of corners and one shared rule: join every pair as close together as any pair gets, which is the edge set of any solid whose corners are all alike. The tests check the tables against Euler'sV - E + F = 2. - Optical illusions (
geomotif.motifs.illusions) —PenroseTriangle,PenroseStairs,ImpossibleCube,NeckerCube,CafeWallandMoirePattern. Both Penrose figures are built in space and then flattened rather than drawn flat and nudged into place: an isometric view sends(t, t, t)to nothing, so a walk that fails to close by exactly that much closes on the page. That is why the staircase's four flights cannot all be the same length, and the tests assert the closure error rather than the picture. examples/plugin/— a complete third-party motif as an installable package: Gielis's superformula, which geomotif's own catalog does not have and does not need. One entry point and one@registerdecorator are the whole contract; once installed it is listed, described, rendered, spec'd and CLI-flagged exactly like a builtin, and geomotif never learns it exists. CI installs it and checks that end to end, since the suite can only simulate the discovery half. The motif documents a real wrinkle rather than hiding it: every corner is a corner in the radius too, so a sample has to land on one to reach it —resolutioncounts segments, so any multiple ofmcatches allmcorners, and exponents below 1 make cusps that no resolution ever resolves.- The
geomotifcommand line (geomotif.cli) —list,show,render,galleryanddemo, in pureargparse. A motif's flags are generated from its dataclass fields, which is what every builtin motif being a dataclass has been buying all along: one declaration drivesdescribe(), the spec format and now the CLI.--merge/--no-mergefor booleans,choicesfor aLiteralparameter (resolved through a named type alias where there is one), and--center 0,0/--region -60,-60,60,60for the geometric ones. A parameter the command line cannot express — a function, another motif, a point set — takes its value from the motif's registered example, so every one of the 146 renders.geomotif gallerywrites all of them to SVG with a manifest whose entries are specs, in about two seconds. plot_design,plot_grid,plot_comparison(geomotif.plotting) — the plotting helpers, generalized from spirals to designs.plot_designdraws each stroke as a line, closing the closed ones, and always draws the loose points, since a scatter motif has nothing else to show; stroke vertices are opt-in, because a four-thousand-vertex fractal with markers is a smear.plot_comparisonis the library's premise in one image: one motif, one point count, several spacing curves. colors moved into aPalettevalue withLIGHTandDARK, so a dark-mode figure is a different argument rather than a different code path.- SVG (
geomotif.io.svg) —to_svgandsave_svg, pure standard library. The design is fitted into the canvas before anything is written rather than scaled by aviewBox, sostroke_widthmeans one unit of the file you are looking at and rounding toprecisionshrinks the file instead of discarding detail a later scale would magnify.flip_ydefaults on, because SVG's y grows downward and every motif here is written the other way up. Loose points become<circle>elements, the motif's name becomes the document<title>, and every attribute is escaped — a color and a title both come from outside. - DXF (
geomotif.io.dxf) —to_dxfandsave_dxf, writing DXF R12, also pure standard library.POLYLINE/VERTEX/SEQENDrather thanLWPOLYLINE, which arrived with R14 and would give up the compatibility R12 was chosen for. A closed path carries the closed flag instead of a repeated final vertex, a named layer is declared in the file's layer table rather than left for the reader to invent, and the header records the drawing extents. DXF is y-up like the motifs themselves, so nothing is mirrored. The output was checked againstezdxfand the SVG againstsvgelements; neither is a dependency, and the suite's own readers are stdlib-only. - Specs (
geomotif.io.spec) —to_spec,from_spec,save_specandload_specserialize the motif and its parameters rather than the points they produced. A mandala's recipe is 1.5 KB against 330 KB of coordinates, it survives a change of point count, and it is a file you can edit by hand. A parameter that is itself a motif — the composers take one — nests as the same{"motif": ..., "params": ...}object, so a spec needs no second notation to describe a mandala's rings; a value dataclass such asBoundsbecomes a{"$type": ...}object naming its class. All 146 motifs round-trip exactly bar the two whose parameter is a Python function, which are defined by code rather than data and refuse by name. Loading a spec will not import a module the file names, only value types from packages that already provide motifs — a spec is data, and data does not get to choose what a process imports. save_design/load_design(geomotif.io.points) — the multi-path writer, for when the strokes matter and not only the coordinates: CSV grows apathcolumn naming the stroke each point belongs to (empty for a design's loose points, which belong to none), TXT separates strokes with the blank line every plotter toolchain already reads as "lift the pen", and JSON keeps the whole structure including the recipe.save_pointsis unchanged and still flattens — the two now differ by exactly one thing.- Voronoi & Delaunay (
geomotif.motifs.voronoi,[scipy]extra) —Delaunay,Voronoi(the diagram as borders, each drawn once, chainable withmerge=True),VoronoiCells(the same map as closed regions, with aninsetfor the cracked-mud look) andLloydRelaxation. All four come from one construction: a site's cell is the region clipped by the perpendicular bisector against each of its Delaunay neighbours, and no other bisector reaches it — which is why one triangulation answers every question the module asks. The tests check that the cells' areas sum to the region's, which fails if a bisector is missed or one that does not belong is used. scipy earns its place on the cocircular case: a plain square grid gives Qhull an arbitrary choice of diagonal, and a hand-rolled triangulator that makes that choice inconsistently contradicts itself. requires=is now honored (geomotif.core.registry) —MotifInfogained anavailableproperty, and the motifs behind an extra import their dependency when a design is built rather than when their module is imported. A machine without scipy can still list, describe and document the Voronoi family; only building one raises, with the install command in the message. CI gained a job that installs the core with no optional dependencies at all and runs the suite, so the zero-dependency promise is checked rather than asserted.- Tilings (
geomotif.motifs.tilings) — eight periodic ones stamped on a lattice (SquareTiling,TriangularTiling,HexagonalTiling,RhombilleTiling,CairoPentagonal,TruncatedSquare,SnubSquareandHerringboneTiling, which works at any brick proportion rather than only two-to-one), the two aperiodic Penrose tilings, andAmmannBeenker.PenroseP3andPenroseP2are built from the same two Robinson triangles and differ only in where the seam runs: along the base gives the thin and thick rhombs, along a leg gives the kite and the dart.AmmannBeenkeris a plainMotifrather than a substitution, built by de Bruijn's multigrid — four families of parallel lines laid across each other, one tile per crossing — because the line arrangement is checkable directly where the eightfold inflation rule is only checkable by eye.TruchetTilingplaces its own cells, since one cell stamped everywhere is exactly what a random tiling is not. - Sacred geometry (
geomotif.motifs.sacred) —VesicaPiscis,SeedOfLife,FlowerOfLife,FruitOfLifeandMetatronsCube, which are one construction carried five steps further each time, plusSriYantraandGoldenRectangle. - Guilloché (
geomotif.motifs.guilloche) —GuillocheRosette,GuillocheBandandGuillochePattern, the engine-turned line work of banknotes and watch dials. Each stroke is the sum of two waves running opposite ways, which is what makes a phase shift change a curve's shape instead of sliding it sideways. - Composers (
geomotif.compose) — motifs made of other motifs:Mandalaand itsRing,Kaleidoscope,Snowflake,SpokePatternandLayeredRings. Their unit is anything with abuild()method, so a composed figure can itself be the unit of another composition. The parameter is calledunitrather thanmotifbecausemotifis the keyspec()reserves for a design's own name. registry.NAME_KEYand a guard that goes with it: registering a motif with a parameter calledmotifnow raises, instead of silently overwriting the design's own name inDesign.metaand leaving a spec that cannot be rebuilt.registry.spec()— a motif's registered name plus its resolved parameters, which is what the bases attach to every design they build. A design can therefore say what made it, and be rebuilt from that.@register(example=...)— constructor arguments producing a representative instance. The gallery will render it; the conformance suite exercises it today, and requires one from any motif whose parameters have no defaults.- Registry-driven conformance suite — one contract, checked against every registered motif automatically: it builds, it is finite, it has extent, it resamples to exactly the count asked for, it is reproducible, its metadata round-trips back through the registry, it exports, and it is documented. Adding a motif from here on costs no test-writing effort.
- Spacing curves —
ReversedSpacing(mirror any curve, including plain callables),CompositeSpacing(chain eases),TableSpacing(draw the curve by hand from control points), andcoerce_spacing, one place that decides what counts as a spacing curve. SpiralBetween(geomotif.motifs.spirals) — the endpoint-constrained arithmetic spiral, preserving the old generator's exact geometry. Also reachable asArchimedeanSpiral.between(...), since it is that same curve parameterized by where it has to start and stop.- A documentation site — MkDocs Material with an API reference generated
from the docstrings by mkdocstrings, guides to the arc-length engine, the
data model, the five export formats, plotting and the command line, plus
docs/extending.md(the three tiers, the conformance contract, publishing a plugin) anddocs/api-policy.md, which writes down what the version number is a promise about. - A generated gallery — every registered motif rendered to SVG at its
registered example, beside the Python, the command line and the spec that
reproduce exactly that file.
tools/gendocs.pywrites it, along with the reference pages anddocs/catalog.md, and doubles as the mkdocshooks:entry so a build is never a step behind the registry. The gallery and the reference are rebuilt every time and never committed; the catalog and the six images the README leads with are committed, because GitHub renders a README without running mkdocs — and are therefore drift-checked bymake docs-check, by CI, and bytests/test_gendocs.py. - The README's numbers are tested.
tests/test_readme.pyparses the family table and checks every count against the registry, that the table covers each family exactly once, and that the totals and the spec example's version are the real ones. The count was wrong within an hour of being written, which is the argument for the test. - Release automation — a Pages workflow that rebuilds and deploys the site
on every push to main, and a PyPI workflow that publishes on a
v*tag through trusted publishing, with no API token in the repository. It refuses to build when the tag and__version__disagree. - Repository documentation —
CONTRIBUTING.md(how to add a motif, and the house style that until now only existed in the code),SECURITY.md,CODE_OF_CONDUCT.md, issue and pull-request templates, and Dependabot on the Actions.
Removed¶
generate_spiral()and thegeomotif.generatormodule. Its behavior lives on asSpiralBetween(start, end, center=..., turns=..., clockwise=...).generate(count, spacing=...).- The
y_downflag. Which way y points is a property of the target coordinate space, not of a motif, so it is nowDesign.flipped_y()orfit(..., flip_y=True)— strictly more general, and it applies to every motif rather than to one. geomotif.curves, renamed togeomotif.core.spacing. This frees the namecurvesfor the named-curve motif family, where a reader will look for it.
Changed¶
describe().paramsnow lists a motif's own parameters before the ones it inherits.dataclasses.fields()gives the opposite order because that is what the generated__init__needs, but it is the wrong order to read:describe("rose").paramsused to open withresolutionanddescribe("tiling.square").paramswithregion, burying the parameter the motif is actually about.geomotif show rosenow leads with--n. Construction is unaffected.- The console script is now
geomotif, and the showcase moved under it asgeomotif demo;python -m geomotifroutes to the same command line.geomotif-demois gone. plot_spiralandplot_spiral_gridbecameplot_designandplot_grid, taking aDesignrather than a list of points -- which is what lets them draw a closed path closed and a scatter motif at all.geomotif.iois now a package rather than a module, split intoio.points(coordinates and designs) andio.spec(recipes). Everything it exported is re-exported unchanged, sofrom geomotif import save_pointsandfrom geomotif.io import save_pointsboth still work.registry.spec()accepts anything withbuild()rather than only aMotifsubclass, matching the rest of the library's structural stance.- Renamed the package
spiralgen→geomotif, ahead of the rework from a single-purpose spiral generator into a general geometric design library. Import name, source tree, distribution name and console script all move together; the demo command is nowgeomotif-demo. Motifdeclares empty__slots__, so@dataclass(frozen=True, slots=True)on a motif now actually takes effect. A single class without slots anywhere in the MRO silently hands every instance a__dict__back.- Ruff gains
D(pydocstyle, numpy convention),ARG,RSEandA, as planned for the point where the public surface grew. - Lowered the Python floor from 3.14 to 3.12, now tested on 3.12, 3.13
and 3.14 across Linux, macOS and Windows. Every modern-syntax flourish in
the codebase (PEP 695
typealiases,@override,match) is 3.12-safe, so nothing was given up to gain the far larger installed base.
Fixed¶
- Modules with
TYPE_CHECKING-guarded imports now carryfrom __future__ import annotations. Those guards were only safe under 3.14's deferred annotation evaluation (PEP 649); on 3.12 and 3.13, importing the package raisedNameErrorat module load. - Registry lookups now import the builtin motif catalog themselves.
Previously
names()reported only the motifs whose modules the caller had already imported, so a fresh interpreter saw an empty registry.
0.1.0 — never released, as spiralgen¶
Recorded so the early history reads honestly. Nothing was ever published under
this name or number; geomotif 1.0.0 is the first release of anything.
Added¶
generate_spiral()— points along an arbitrary spiral between a start and end point around a configurable center (default origin), with direction, extra turns, pluggable spacing, and ay_downflag for screen-style coordinate systems.save_points()— export coordinates to CSV, TXT/TSV, or JSON with optional rounding.- True arc-length positioning (default): equal spacing means equal real x,y
distance between consecutive points;
arc_length=Falsefor parametric spacing. - Spacing curve family:
LinearSpacing,PowerSpacing,QuadraticSpacing,CubicSpacing,SineSpacing,ExponentialSpacing,CircularSpacing,SmoothstepSpacing, all built on theSpacingCurveABC. - Optional matplotlib helpers (
geomotif.plotting) behind theplotextra. geomotif-democonsole command /python -m geomotifshowcase.