Skip to content

geomotif.cli

The geomotif command line.

Pure :mod:argparse, so the zero-dependency core stays that way::

geomotif list                                  # every motif, by family
geomotif list --family fractal
geomotif show rose                             # docs, parameters, defaults
geomotif render rose --n 5 --samples 400 --out rose.svg
geomotif render spiral.golden --samples 300 --ease power:2.5 --out s.csv
geomotif render fractal.hilbert --depth 6 --out h.dxf --fit 800x800
geomotif render fractal.hilbert --out h.gif --motion draw-on --frames 60
geomotif render fractal.hilbert --out h.gif --frames 60 --hold 12
geomotif render mandala --out m.svg --paper a4 --optimize    # for a plotter
geomotif render --spec my-design.json --out out.svg
geomotif explore rose --out rose.html          # sliders for its parameters
geomotif gallery --out docs/gallery
geomotif demo

A motif's flags are generated from its dataclass fields, which is the point of every builtin motif being one: --n, --depth, --center 0,0 and the rest all come from the same declaration that drives describe() and the spec format. Nothing is written twice.

Two things follow from generating flags rather than writing them.

Not every parameter can be said on a command line. A motif parameterized by a Python function, by another motif, or by a point set has no sensible flag. Those take their value from the motif's registered example instead, so every motif in the catalog still renders -- geomotif render voronoi.cells gives you the example's point set, and --inset still works on top of it.

A generic flag and a motif parameter share one namespace. The handful of names this module claims are listed in :data:RESERVED; a motif parameter that collides with one is not given a flag, and there is a test that no builtin does. That is why the sampling options are --samples, --stride and --ease rather than the more obvious words, which are all taken by motifs.

Functions:

Name Description
main

Run the command line and return the process exit code.

build_parser

Build the parser, with motif's own flags added to render if given.

main

main(argv: Sequence[str] | None = None) -> int

Run the command line and return the process exit code.

Source code in src/geomotif/cli.py
def main(argv: Sequence[str] | None = None) -> int:
    """Run the command line and return the process exit code."""
    args = list(sys.argv[1:] if argv is None else argv)
    motif = _peek_motif(args)
    parser = build_parser(motif)
    parsed = parser.parse_args(_glue_coordinates(args, _coordinate_flags(motif)))
    handlers = {
        "list": _list,
        "show": _show,
        "render": _render,
        "explore": _explore,
        "gallery": _gallery,
        "demo": _demo,
    }
    try:
        return handlers[parsed.command](parsed)
    except (KeyError, ValueError, TypeError, OSError) as exc:
        # KeyError stringifies with its own quotes around the whole message,
        # which reads badly on a terminal.
        message = exc.args[0] if isinstance(exc, KeyError) and exc.args else exc
        print(f"geomotif: error: {message}", file=sys.stderr)
        return 2

build_parser

build_parser(motif: MotifInfo | None = None) -> ArgumentParser

Build the parser, with motif's own flags added to render if given.

Source code in src/geomotif/cli.py
def build_parser(motif: MotifInfo | None = None) -> argparse.ArgumentParser:
    """Build the parser, with ``motif``'s own flags added to ``render`` if given."""
    parser = argparse.ArgumentParser(
        prog="geomotif",
        description="Generate geometric designs and write them out.",
    )
    parser.add_argument("--version", action="version", version=f"geomotif {__version__}")
    sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")

    listing = sub.add_parser("list", help="every registered motif, grouped by family")
    listing.add_argument("--family", help="only this family")
    listing.add_argument("--names", action="store_true", help="bare names, one per line")

    show = sub.add_parser("show", help="one motif's documentation and parameters")
    show.add_argument("name", help="registered motif name")

    render = sub.add_parser(
        "render",
        help="build a motif and write it out",
        description=(
            "Build a motif and write it out. Without --out the points go to stdout as "
            "CSV. Run 'geomotif show NAME' or 'geomotif render NAME --help' to see a "
            "motif's own flags."
        ),
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    render.add_argument("name", nargs="?", help="registered motif name")
    render.add_argument("--spec", type=pathlib.Path, help="read the motif from a spec file instead")
    render.add_argument(
        "--animation",
        type=pathlib.Path,
        metavar="PATH",
        help="read a spec with an 'animation' key and write the moving picture to --out",
    )
    render.add_argument("--samples", type=int, metavar="N", help="resample to N points")
    render.add_argument(
        "--stride", type=float, metavar="D", help="a point every D units of real distance"
    )
    render.add_argument(
        "--ease",
        type=_spacing,
        metavar="CURVE",
        help=f"spacing curve, as name[:arg[:arg]] from {sorted(SPACINGS)}",
    )
    render.add_argument("--by", choices=("length", "parameter"), default="length")
    render.add_argument("--distribute", choices=("length", "even", "per_path"), default="length")
    render.add_argument("--fit", type=_size, metavar="WxH", help="scale onto a canvas")
    render.add_argument(
        "--canvas",
        type=_size,
        metavar="WxH",
        help="pixel canvas for a .gif, .png or .jpg",
    )
    render.add_argument("--motion", choices=MOTIONS, default="draw-on", help="how a .gif animates")
    render.add_argument("--frames", type=int, default=48, metavar="N", help="frames in a .gif")
    render.add_argument(
        "--hold",
        type=_nonnegative_int,
        metavar="N",
        help="how long .gif sits on the finished drawing, in frames (default: a quarter of --frames)",
    )
    render.add_argument("--fps", type=float, default=20.0, metavar="X", help="a .gif's frame rate")
    render.add_argument(
        "--loop",
        type=_nonnegative_int,
        default=0,
        metavar="N",
        help="times a .gif plays (0=forever)",
    )
    render.add_argument(
        "--stroke-width",
        type=_positive_int,
        default=1,
        metavar="PX",
        help="stroke width, in pixels",
    )
    render.add_argument(
        "--dot-radius",
        type=_positive_int,
        metavar="PX",
        help="loose-point radius, in pixels (default: --thickness)",
    )
    render.add_argument("--ink", default="#0b0b0b", help="default stroke color, a name or #hex")
    render.add_argument("--background", default="#ffffff", help="canvas color, a name or #hex")
    render.add_argument(
        "--transparent",
        action="store_true",
        help="leave a .png or .gif's background empty instead of painting it",
    )
    render.add_argument(
        "--padding",
        type=_nonnegative_float,
        default=8.0,
        metavar="PX",
        help="margin around a raster drawing, in pixels",
    )
    render.add_argument("--antialias", action="store_true", help="smooth a raster drawing's edges")
    render.add_argument(
        "--aa-level",
        type=_positive_int,
        default=8,
        metavar="N",
        help="with --antialias, shades an edge may blend into per color pair",
    )
    render.add_argument(
        "--dither",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="error-diffuse a .gif's palette round-off (--no-dither turns it off)",
    )
    render.add_argument(
        "--compression",
        type=_nonnegative_int,
        default=6,
        choices=range(10),
        metavar="0-9",
        help="zlib level a .png is deflated at (0 fast, 9 small)",
    )
    render.add_argument(
        "--quality",
        type=_nonnegative_int,
        default=85,
        choices=range(101),
        metavar="0-100",
        help="how much a .jpg keeps (0 small, 100 faithful)",
    )
    render.add_argument(
        "--paper",
        choices=sorted(PAPER),
        help="write a .svg at this paper size, in real millimeters, for a plotter",
    )
    render.add_argument("--landscape", action="store_true", help="turn --paper on its side")
    render.add_argument(
        "--margin",
        type=float,
        default=10.0,
        metavar="MM",
        help="with --paper, border to leave unplotted, in millimeters",
    )
    render.add_argument(
        "--optimize",
        action="store_true",
        help="join strokes that meet and order them so the pen travels less",
    )
    render.add_argument(
        "--snap", type=float, metavar="STEP", help="move every point onto a grid this size"
    )
    render.add_argument(
        "--snap-mode",
        choices=SNAP_MODES,
        default="half-even",
        help="which way --snap sends a point between two grid lines",
    )
    render.add_argument(
        "--keep-duplicates",
        action="store_true",
        help="with --snap, keep the points a coarse grid stacked up rather than dropping them",
    )
    render.add_argument("--precision", type=int, metavar="N", help="decimal places to write")
    render.add_argument("--title", help="title for the SVG document or the figure")
    render.add_argument("--out", type=pathlib.Path, help=f"output file; {sorted(_WRITERS)}")
    if motif is not None:
        _add_motif_flags(render, motif)

    explore = sub.add_parser(
        "explore",
        help="one HTML page with a slider per parameter",
        description=(
            "Write a self-contained page with a slider for every parameter a slider "
            "can move. Every frame is rendered ahead of time and embedded, so the "
            "page needs no server and works offline."
        ),
    )
    explore.add_argument("names", nargs="*", help="registered motif names")
    explore.add_argument("--family", help="every motif in this family as well")
    explore.add_argument("--out", type=pathlib.Path, default=pathlib.Path("explore.html"))
    explore.add_argument("--steps", type=int, default=DEFAULT_STEPS, help="values per slider")
    explore.add_argument("--size", type=int, default=DEFAULT_SIZE, help="frame canvas, in units")
    explore.add_argument(
        "--samples", type=int, metavar="N", help="resample each frame, to keep the page small"
    )

    gallery = sub.add_parser("gallery", help="render every motif to SVG, with a manifest")
    gallery.add_argument("--out", type=pathlib.Path, default=pathlib.Path("gallery"))
    gallery.add_argument("--family", help="only this family")
    gallery.add_argument("--size", type=int, default=320, help="SVG canvas, in user units")

    demo = sub.add_parser("demo", help="the spacing-curve showcase (needs matplotlib)")
    demo.add_argument("out", nargs="?", help="save the figure here instead of opening a window")
    return parser