Exporting¶
Six file formats and one recipe format, and none of them needs a dependency. The core is zero-dependency all the way out to the file.
| You want | Function | Reads back |
|---|---|---|
| coordinates for another tool | save_points |
no |
| coordinates with the strokes kept apart | save_design |
JSON only |
| a picture | save_svg |
no |
| something to cut, mill or plot | save_dxf |
no |
| the recipe, not the points | save_spec |
yes |
| a still picture | save_png, save_jpeg |
no |
| a moving picture | save_gif |
no |
Coordinates¶
from geomotif import save_points
save_points(design, "points.csv") # x,y header + one row per point
save_points(design, "points.txt", precision=0) # tab-separated whole integers
save_points(design, "points.json", precision=2) # JSON array of [x, y] pairs
The format comes from the file suffix (.csv, .txt/.tsv, .json) or from
fmt=. precision rounds; precision=0 writes whole integers rather than
1.0, and each step below that rounds to tens, hundreds and so on.
save_points flattens everything into one list, which is exactly right when
the destination only wants coordinates — a spreadsheet, a game map, a particle
system.
Snapping to a grid¶
precision rounds the file. Each writer has its own, they do not have to
agree, and the design in memory still holds every digit — so a plot of it is
not quite the thing you exported.
Design.snapped rounds the design
instead. Do it once, and every writer, the plot and the gallery all show the
same numbers:
aligned = design.snapped() # nearest whole unit
half_mm = design.snapped(0.5) # a grid no number of decimals can express
lattice = design.snapped(5.0, mode="half-up")
Three things it does that precision cannot.
Any grid, not only powers of ten. Decimal places give you ones, tenths and
hundredths. snapped(0.5), snapped(0.25) and snapped(5.0) are the asks
that actually turn up — half-millimeter plotter steps, a pegboard, a tile size.
A rounding rule you chose. The default half-even is what Python's round
and therefore precision have always done: a coordinate exactly halfway goes
to the even neighbour, so a long list of halves does not drift upward.
half-up sends it away from zero instead, which is the rounding most people
were taught. floor, ceil and trunc always go the same way, for a
one-sided tolerance.
half-up means away from zero
Not toward +∞. A design and its mirror image have to snap to mirror-image grids, and the toward-+∞ reading would shift one of the two by a whole step at every halfway point.
Somewhere to put the collapsed points. A coarse grid lands neighbouring
points on top of each other, and those are zero-length segments: no ink, and a
pen-down/pen-up the plotter spends time on anyway. snapped drops them, along
with any stroke left with fewer than two points, and carries each surviving
stroke's style across with it. Pass drop_duplicates=False to keep the point
count exactly as it was — what a fixed-size buffer or a per-point parallel
array needs:
design.snapped(10.0) # 200 points in, maybe 52 out
design.snapped(10.0, drop_duplicates=False) # 200 in, 200 out, some identical
Only consecutive repeats go. A point landing on an earlier, non-adjacent point of the same stroke is a crossing rather than a redundancy — a figure eight on a coarse grid still has to go round both loops.
snapped puts the points on the grid; precision=0 writes them as 3 rather
than 3.0. Use both:
save_points(design.snapped(), "points.csv", precision=0)
save_points(design.snapped(0.5), "points.csv", precision=1)
Snapping and arc-length spacing pull against each other
Equal spacing here means equal real distance, and a grid does not care. Points an equal distance apart come out equal only to within half a step. Snap after resampling, and keep the step well under the spacing if the evenness is what you were there for.
The picture writers have their own version of this: both to_svg and
save_plotter_svg fit the design into the canvas as they write, so they
rescale a snapped design and the grid does not survive into the file. Snap
is exact for save_points, save_design, the CLI's stdout, and DXF —
which writes the design's own coordinates untouched.
Coordinates with the strokes kept apart¶
When where the pen lifts matters, flattening loses the only thing you needed.
save_design keeps it:
from geomotif import load_design, save_design
save_design(design, "design.csv") # a `path` column naming each point's stroke
save_design(design, "design.txt") # a blank line between strokes
save_design(design, "design.json") # structured -- and the only one that reads back
design = load_design("design.json")
The CSV path column is empty for the loose points, because they belong to no
stroke. The TXT form separates strokes with a blank line, which is what plotter
toolchains already read as "lift the pen".
load_design decodes the metadata without rebuilding the motif, so a design
saved by a plugin still loads on a machine that does not have that plugin
installed.
SVG¶
from geomotif import save_svg, to_svg
save_svg(design, "design.svg", width=800, background="#fff")
markup = to_svg(design, width=800, height=800, stroke="#333", stroke_width=1.5)
Two decisions are worth knowing about.
The writer transforms the coordinates, not the canvas. The design is fitted
into the canvas before anything is written, rather than being scaled by a
viewBox. So stroke_width=1.0 means one unit of the file you are actually
looking at, whatever the design happened to measure — and rounding to
precision shrinks the file rather than throwing away detail that a later
scale would magnify back into visible steps.
y is flipped by default. SVG's y-axis grows downward and every motif here is
drawn the other way up, so flip_y=True is the default and a design comes out
the way you drew it.
Give width and height for an exact canvas, one of them to keep the
proportions, or neither to keep the design's own size plus padding. Loose
points become <circle> elements — that is the dot-art path — and title=
becomes the document <title>, so a gallery file labels itself.
DXF¶
DXF R12, deliberately. R12 is the version everything reads, so the writer
emits POLYLINE/VERTEX/SEQEND rather than LWPOLYLINE — which arrived
with R14 and would give up the compatibility R12 was chosen for.
A closed path carries the closed flag rather than a repeated final vertex, the layer is declared in the file's own layer table, and the header records the drawing extents so "zoom to fit" works the moment it opens. DXF is y-up like the motifs themselves, so nothing is mirrored and the design keeps its own measurements.
Checked against real readers
Both writers were validated against third-party parsers — ezdxf and
svgelements — during development, and the test suite takes every motif in
the catalog through both formats and parses them back with readers
written in nothing but the standard library. Neither third-party parser is
a dependency.
Specs: the recipe, not the points¶
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:
from geomotif import load_spec, save_spec
save_spec(motif, "design.json")
motif = load_spec("design.json")
design = motif.generate(2000) # ...at whatever resolution you want today
One nested shape at every depth. A parameter that is itself a motif — the
composers take one — is written as the same {"motif": ..., "params": ...}
object as the whole file, so a mandala's rings need no second notation. A
parameter that is a value dataclass (Bounds, Ring, IFSMap) becomes a
{"$type": ...} object naming its class.
Every motif in the catalog round-trips exactly, but the two whose parameter
is a Python function: polar.expression and string-art.envelope are
parameterized by code rather than by data, and asking for their spec says so by
name rather than writing a file that will not load.
A spec is data, and data does not get to choose what you import
Loading a spec will not import a module the file names. $type resolution
is restricted to geomotif itself plus any package that already declares a
geomotif.motifs entry point — the packages that provide motifs on this
machine anyway — and the resolved object has to be a dataclass class.
Anything else is refused with the name it tried.
Which one to reach for¶
| You want | Reach for |
|---|---|
| Points somewhere else | save_points |
| Driving a pen plotter, laser or mill | save_dxf, or save_design to .txt |
| A web page or an editor | save_svg |
| A finished picture, anywhere, nothing installed | save_png |
| A smaller file, at the cost of lossiness | save_jpeg (--quality 0-100) |
| A transparent background | save_png (..., transparent=True) or save_gif (..., transparent=True) |
| To show how it is drawn | save_gif, and Animation |
| To actually plot it | save_plotter_svg, and Plotting it for real |
| To save your work and change the resolution later | save_spec |
The longer form, for the cases a one-liner does not settle:
- A single picture where a smaller file matters more than exactness →
save_jpeg(--quality 0-100). Caveat: this is only true for photographic content — a geometric design on a flat background is where JPEG is weakest and zlib is strongest, sosave_pngusually wins on the same picture despite being lossless. - Saving your work, so you can change your mind about the resolution later →
save_spec. It is the only one that is still useful after you have edited it.
A word about JPEG
The JPEG writer meets the same verification bar as the PNG and GIF writers
— the suite reads its file back and finds the source, and libjpeg and
Pillow open it without complaint. Since 1.2.1 the fine-detail (AC) blocks
are written in the standard zig-zag order and the quantization tables in
scan order, so an external viewer renders the file exactly as this
library's own reader does, to within IDCT rounding. Reach for save_jpeg
whenever a smaller file is the point; save_png or save_gif remain the
choice when a lossless picture matters.