Guide
A hands-on tour of Eido — from first shapes to generative art.
Drawing
Everything in Eido starts with shapes. You describe what something looks like — not how to draw it. There's no canvas, no drawing loop, no mutable state. Just data that says "here's a circle at this position with this color." Eido reads that description and produces the image.
Shapes
You describe a shape as a map — a collection of key-value pairs that says what the shape is, where it goes, and how it looks:
Rectangle
{:node/type :shape/rect
:rect/xy [50 50] ;; top-left corner position
:rect/size [200 100] ;; width and height
:style/fill [:color/name "dodgerblue"]}
Add rounded corners with :rect/corner-radius:
{:node/type :shape/rect
:rect/xy [50 50]
:rect/size [200 100]
:rect/corner-radius 16
:style/fill [:color/name "dodgerblue"]}
Circle
{:node/type :shape/circle
:circle/center [200 200] ;; center point
:circle/radius 80 ;; radius in pixels
:style/stroke {:color [:color/name "black"] :width 2}}
Ellipse
{:node/type :shape/ellipse
:ellipse/center [200 200]
:ellipse/rx 120 ;; horizontal radius
:ellipse/ry 60 ;; vertical radius
:style/fill [:color/name "indianred"]}
Arc
A partial ellipse — like a pie slice or an open curve:
{:node/type :shape/arc
:arc/center [200 200]
:arc/rx 80 :arc/ry 80
:arc/start 0 :arc/extent 270 ;; degrees
:arc/mode :pie ;; :open, :chord, or :pie
:style/fill [:color/name "gold"]}
Line
{:node/type :shape/line
:line/from [50 50]
:line/to [350 250]
:style/stroke {:color [:color/name "black"] :width 2}}
Path
For anything that isn't a basic shape, use a path. Paths are sequences of drawing commands — move to a point, draw a line, draw a curve, and close the shape:
{:node/type :shape/path
:path/commands [[:move-to [100 200]] ;; pick up the pen
[:line-to [200 50]] ;; draw a straight line
[:curve-to [250 0] [300 100] ;; cubic bezier curve
[300 200]] ;; (two control points + end)
[:quad-to [250 250] ;; quadratic bezier curve
[200 200]] ;; (one control point + end)
[:close]] ;; connect back to start
:style/fill [:color/name "gold"]}
Convenience helpers
The eido.scene namespace provides shortcuts for common shapes:
(require '[eido.scene :as scene])
(scene/regular-polygon [200 200] 80 6) ;; hexagon
(scene/star [200 200] 80 35 5) ;; 5-pointed star
(scene/triangle [100 200] [200 50] [300 200])
(scene/smooth-path [[50 200] [150 50] [250 200] [350 50]]) ;; smooth curve through points
Text
Text in Eido is not rasterized pixels — it's converted to vector paths, just like any other shape. That means text works with everything: gradient fills, strokes, transforms, clipping, even 3D extrusion.
Simple text
{:node/type :shape/text
:text/content "Hello"
:text/font {:font/family "Serif" :font/size 48 :font/weight :bold}
:text/origin [50 100] ;; baseline-left anchor
:text/align :center ;; :left (default), :center, :right
:style/fill [:color/name "black"]}
Per-glyph control
Style each character independently — great for rainbow text, animated reveals, or creative typography:
{:node/type :shape/text-glyphs
:text/content "COLOR"
:text/font {:font/family "SansSerif" :font/size 64}
:text/origin [50 100]
:text/glyphs [{:glyph/index 0 :style/fill [:color/name "red"]}
{:glyph/index 1 :style/fill [:color/name "limegreen"]}]
:style/fill [:color/name "gray"]} ;; default for unlisted glyphs
Text on a path
Make text follow any curve:
{:node/type :shape/text-on-path
:text/content "ALONG A CURVE"
:text/font {:font/family "SansSerif" :font/size 24}
:text/path [[:move-to [50 200]]
[:curve-to [150 50] [350 50] [450 200]]]
:text/offset 10 ;; start distance along path
:text/spacing 1 ;; extra inter-glyph spacing
:style/fill [:color/name "black"]}
Fonts reference system fonts by name. Java's built-in fonts — "Serif", "SansSerif", "Monospaced" — work on every system.
Styling
Shapes on their own are just geometry. Styling is what makes them visible — fills, strokes, gradients, and textures. Eido gives you a wide range of options, from flat colors to procedural hatching, all specified as data in the same shape map.
Colors
Eido understands several color formats. Use whichever feels natural — they all work everywhere:
[:color/name "coral"] ;; 148 CSS named colors
[:color/rgb 255 127 80] ;; red, green, blue (0-255)
[:color/rgba 255 127 80 0.5] ;; with transparency (0-1)
[:color/hsl 16 1.0 0.66] ;; hue, saturation, lightness
[:color/hex "#FF7F50"] ;; hex notation
All five lines above describe the same color — coral. Use whichever format suits your workflow.
Color manipulation
Adjust colors programmatically — lighten, darken, blend, or shift the hue:
(require '[eido.color :as color])
(color/lighten [:color/name "red"] 0.2) ;; lighter
(color/darken [:color/name "red"] 0.2) ;; darker
(color/saturate [:color/name "red"] 0.3) ;; more vivid
(color/rotate-hue [:color/name "red"] 120) ;; shift hue
(color/lerp color-a color-b 0.5) ;; blend 50/50
Perceptually uniform color (OKLAB / OKLCH)
RGB and HSL are device-oriented — equal numerical steps don't produce equal visual steps. OKLAB and OKLCH are perceptually uniform: interpolation stays vivid instead of passing through muddy grays.
[:color/oklab 0.63 0.22 0.13] ;; L (lightness), a, b
[:color/oklch 0.63 0.26 29] ;; L, C (chroma), h (hue degrees)
;; Convenience constructors:
(color/oklab 0.63 0.22 0.13)
(color/oklch 0.7 0.15 200)The real power is in OKLAB interpolation — blending red and cyan in RGB produces gray, but in OKLAB the midpoint stays chromatic:
;; RGB interpolation (passes through gray):
(color/lerp :red :cyan 0.5)
;; OKLAB interpolation (stays vivid):
(color/lerp :red :cyan 0.5 {:space :oklab})
;; Convenience shorthand:
(color/lerp-oklab :red :cyan 0.5)
Use rgb->oklab and rgb->oklch to inspect any color in perceptual coordinates:
(color/rgb->oklab 255 0 0) ;; => [0.628 0.225 0.126]
(color/rgb->oklch 255 0 0) ;; => [0.628 0.258 29.2]Color contrast and distance
Check whether two colors have enough visual separation — for readability, plotter ink on paper, or accessibility:
;; WCAG luminance contrast ratio (1 = identical, 21 = max)
(color/contrast :black :white) ;; => 21.0
(color/contrast :red :darkred) ;; => ~2.1
;; Perceptual distance in OKLAB space
(color/perceptual-distance :red :blue) ;; => ~0.52
;; Minimum contrast in a palette (all pairs)
(palette/min-contrast [:red :coral :gold :navy])
;; Sort palette from dark to light (perceptual lightness)
(palette/sort-by-lightness my-palette)Extracting palettes from images
Photograph a landscape, scan a painting, or screenshot a reference — then extract its dominant colors as a palette:
(require '[eido.color.palette :as palette])
;; Extract 5 dominant colors from a photograph:
(palette/from-image "photo.jpg" 5 {:seed 42})
;; Preview the result:
(show (palette/swatch (palette/from-image "sunset.jpg" 6)))Uses k-means clustering in OKLAB perceptual color space — colors that look similar get grouped together. Result is sorted dark to light.
Strokes
Strokes are the outlines around shapes. You can control the width, the shape of line endings (caps), and add dashed patterns:
;; Thick rounded caps (top left) vs. butt caps (top right)
{:style/stroke {:color [:color/name "black"]
:width 6
:cap :round}} ;; :butt, :round, or :square
;; Dashed lines in different patterns
{:style/stroke {:color [:color/name "royalblue"]
:width 3
:dash [15 8]}} ;; alternating dash and gap lengths
A shape can have both a fill and a stroke — the stroke is drawn on top.
Gradients
Instead of a flat color, fill a shape with a smooth color transition. Eido supports two kinds:
Linear gradient
Colors transition along a line from one point to another:
{:style/fill {:gradient/type :linear
:gradient/from [0 0] ;; start point
:gradient/to [200 0] ;; end point
:gradient/stops [[0.0 [:color/rgb 255 0 0]] ;; red at start
[1.0 [:color/rgb 0 0 255]]]}}
Radial gradient
Colors radiate outward from a center point:
{:style/fill {:gradient/type :radial
:gradient/center [100 100]
:gradient/radius 100
:gradient/stops [[0.0 [:color/name "white"]]
[1.0 [:color/name "black"]]]}}
Add as many color stops as you want for multi-color transitions. Any color format works in stops.
Pattern Fills
Beyond solid colors and gradients, Eido supports texture-like fills that give shapes a hand-crafted look:
Hatching
Parallel lines drawn across a shape — like pen-and-ink cross-hatching:
{:style/fill {:fill/type :hatch
:hatch/angle 45 ;; line angle in degrees
:hatch/spacing 4 ;; distance between lines
:hatch/stroke-width 1
:hatch/color [:color/rgb 0 0 0]}}
Stippling
Random dots packed inside a shape — like pointillism:
{:style/fill {:fill/type :stipple
:stipple/density 0.6 ;; how packed (0-1)
:stipple/radius 1.0 ;; dot size
:stipple/seed 42 ;; for reproducibility
:stipple/color [:color/rgb 0 0 0]}}
Custom tile patterns
Tile any collection of shapes as a repeating pattern:
{:style/fill {:fill/type :pattern
:pattern/size [20 20] ;; tile size
:pattern/nodes [...]}}Composition
Once you have shapes, you'll want to combine them — layer them, group them, clip one inside another, or transform them as a unit. Composition tools let you build complex images from simple pieces without losing control.
Groups
Groups let you treat multiple shapes as one unit. Any style, transform, or effect applied to the group affects all its children. Styles inherit — children get the group's fill color unless they specify their own. Opacity multiplies through the tree.
{:node/type :group
:node/transform [[:transform/translate 200 200]]
:style/fill [:color/name "red"]
:node/opacity 0.8
:group/children
[{:node/type :shape/circle ;; inherits red fill
:circle/center [0 0]
:circle/radius 80}
{:node/type :shape/rect
:rect/xy [-30 -30]
:rect/size [60 60]
:style/fill [:color/name "blue"] ;; overrides with blue
:node/opacity 0.5}]}
Clipping
Clipping restricts a group's visible area to a shape — like looking through a circular window. Here, three overlapping colored rectangles are clipped to a circle:
{:node/type :group
:group/clip {:node/type :shape/circle
:circle/center [150 150]
:circle/radius 100}
:group/children
[{:node/type :shape/rect
:rect/xy [50 50] :rect/size [100 200]
:style/fill [:color/name "red"]}
{:node/type :shape/rect
:rect/xy [150 50] :rect/size [100 200]
:style/fill [:color/name "royalblue"]}
{:node/type :shape/rect
:rect/xy [50 50] :rect/size [200 100]
:style/fill [:color/rgba 255 220 0 0.5]}]}
Compositing
Control how overlapping shapes blend together. Opacity makes shapes see-through, and blend modes combine colors in different ways — like layer modes in Photoshop:
;; Two overlapping circles — the blue one is 60% transparent
{:node/type :shape/circle
:circle/center [110 100] :circle/radius 70
:style/fill [:color/name "red"]}
{:node/type :shape/circle
:circle/center [190 100] :circle/radius 70
:style/fill [:color/name "royalblue"]
:node/opacity 0.6}
Available blend modes: :src-over (default), :multiply, :screen, :overlay, and more.
Margin control
Clip all artwork to an inset rectangle — a clean way to enforce margins:
(require '[eido.scene :as scene])
;; 30px margin on all sides:
(scene/with-margin my-scene 30)Uses existing :group/clip infrastructure — wraps all nodes in a clipped group. Works with any content.
Transforms
Move, rotate, scale, and skew any shape or group. Here, five squares are translated to different positions and progressively rotated:
;; Each square is translated and rotated a bit more than the last
{:node/transform [[:transform/translate 100 80]
[:transform/rotate 0.3]]} ;; angle in radians
Transforms compose through the tree — a shape inside a translated group inherits the group's transform, then applies its own on top.
Generative
This is where Eido really shines for artists. Instead of placing every shape by hand, you describe rules and parameters — and the system generates complex, organic compositions from them. Every generative tool is deterministic: give it the same seed number and you get the exact same output, every time. Change the seed and you get a fresh variation. This is how artists create long-form series of unique but related works.
If you're coming from Processing, p5.js, or similar tools, the concepts will feel familiar — noise, particles, flow fields — but in Eido they're all data in, data out. No draw loop, no mutable state. You describe what you want, and Eido produces it.
Layouts: Grids, Lines & Circles
Before diving into algorithms, you'll want ways to arrange shapes in patterns. These layout helpers take a rule (a function) and apply it at every position in a grid, along a line, or around a circle:
Grid
Place something at every cell in a grid. Your function receives the column and row numbers — use them to vary size, color, or anything else:
(scene/grid 10 10
(fn [col row]
{:node/type :shape/circle
:circle/center [(+ 30 (* col 40)) (+ 30 (* row 40))]
:circle/radius 15
:style/fill [:color/rgb (* col 25) (* row 25) 128]}))
Distribute along a line
Spread shapes evenly between two points. The t parameter goes from 0 at the start to 1 at the end — use it for gradual size or color changes:
(scene/distribute 8 [50 200] [750 200]
(fn [x y t] ;; t is progress 0 to 1
{:node/type :shape/circle
:circle/center [x y]
:circle/radius (+ 5 (* 20 t))
:style/fill [:color/rgb 0 0 0]}))
Radial arrangement
Arrange shapes in a circle — like numbers on a clock face:
(scene/radial 12 [200 200] 120 ;; 12 items around [200,200] radius 120
(fn [x y angle]
{:node/type :shape/circle
:circle/center [x y]
:circle/radius 15
:style/fill [:color/rgb 200 0 0]}))
Contour Lines
Contour lines connect points of equal value — like elevation lines on a topographic map. Think of it as slicing through a noise landscape at different heights and tracing where each slice hits. The result is those organic, flowing lines you see in terrain maps and generative posters:
(require '[eido.gen.contour :as contour])
{:node/type :contour
:contour/bounds [0 0 500 400]
:contour/opts {:thresholds [0.0 0.2 0.4] ;; which "heights" to trace
:resolution 3 ;; detail level
:noise-scale 0.012 ;; smaller = smoother hills
:seed 42} ;; change for a new landscape
:style/stroke {:color [:color/rgb 100 150 100] :width 1}}
Noise
Noise is the secret ingredient behind organic-looking generative art. Unlike plain random numbers (which look like TV static), noise produces smooth randomness — nearby points get similar values, creating natural-looking gradients, hills, and flows:
(require '[eido.gen.noise :as noise])
;; Smooth 2D noise: feed in a position, get a value from -1 to 1
(noise/perlin2d x y)
;; 3D noise: use the third dimension as time for animated effects
(noise/perlin3d x y z)
;; Fractal noise: layer multiple scales for richer detail
(noise/fbm noise/perlin2d x y
{:octaves 4 :seed 42})
The :seed controls which particular landscape you get. Same seed, same landscape. The :octaves parameter in fbm adds layers of detail — like zooming into a coastline where you see detail at every scale.
Simplex noise
OpenSimplex2 has fewer directional artifacts than Perlin — smoother, more organic patterns. Same API, drop-in replacement:
(noise/simplex2d x y) ;; 2D simplex noise
(noise/simplex3d x y z) ;; 3D simplex noise
(noise/simplex2d x y {:seed 42}) ;; seeded
;; Works with all fractal variants:
(noise/fbm noise/simplex2d x y {:octaves 6})
(noise/turbulence noise/simplex2d x y {:octaves 4})4D noise and seamless loops
4D noise is essential for seamlessly looping animated noise. Use the 3rd and 4th dimensions as a circular time parameter:
;; 4D Perlin and simplex noise:
(noise/perlin4d x y z w)
(noise/simplex4d x y z w {:seed 42})
;; Seamless loop trick — walk a circle in the z/w plane:
(let [r 1.0
t (/ frame total-frames)]
(noise/simplex4d x y
(* r (Math/cos (* t 2 Math/PI)))
(* r (Math/sin (* t 2 Math/PI)))))The loop radius r controls how different each frame is from its neighbors — smaller = smoother transitions, larger = more variation.
Noise preview
Tweak noise parameters and see the result instantly at the REPL:
;; Preview any noise function as a grayscale image:
(show (noise/preview noise/perlin2d))
;; Preview FBM with custom parameters:
(show (noise/preview
(fn [x y] (noise/fbm noise/perlin2d x y {:octaves 6 :seed 42}))
{:width 512 :height 512 :scale 0.01}))Particles
Particle systems simulate many small objects — sparks, snowflakes, smoke, confetti — moving under physics forces. You describe the behavior (where particles spawn, how long they live, what forces act on them) and Eido simulates the result. Same seed, same simulation, every time.
(require '[eido.gen.particle :as particle])
;; Pre-compute 60 frames of fire particles
(let [frames (vec (particle/simulate
(particle/with-position particle/fire [200 350])
60 {:fps 30}))]
;; Each frame is a vector of shape nodes — compose freely
(eido/render
(anim/frames 60
(fn [t]
{:image/size [400 400]
:image/background [:color/rgb 20 15 10]
:image/nodes (nth frames (int (* t 59)))}))
{:output "fire.gif" :fps 30}))
Built-in presets: particle/fire, particle/snow, particle/sparks, particle/confetti, particle/smoke, particle/fountain. Start from a preset and tweak it — change gravity, lifetime, colors — using assoc and update.
Controlling Randomness
Plain randomness gives you chaos. Shaped randomness gives you art. Instead of "pick any number," you can say things like "pick a size, but most should be small with occasional large ones" or "choose a color, but make red rare." That's what this module is for.
The key idea: every function takes a seed — a number that locks the result. Same seed, same output, always. Change the seed and you get a fresh variation. This is how you explore, then freeze a result you like.
Spread evenly vs. cluster naturally
uniform scatters values evenly across a range (top row). gaussian clusters them around a center with natural falloff (bottom row) — the "bell curve" shape you see everywhere in nature:
(require '[eido.gen.prob :as prob])
;; Top: 80 dots spread evenly between 20 and 380
(prob/uniform 80 20.0 380.0 42)
;; Bottom: 80 dots clustered around 200 (mean=200, sd=50)
(prob/gaussian 80 200.0 50.0 99)
Weighted choice — controlling frequency
This is how you control what appears most often. Give each option a weight — higher weight means more likely. Here, circles appear 6x more often than triangles:
;; 60% circles (blue), 30% squares (gold), 10% triangles (red)
(prob/pick-weighted [:circle :square :triangle]
[6 3 1] seed)
Try changing the weights to see how the balance shifts. Weights of [1 1 1] give equal frequency; [10 1 1] makes the first option dominant.
Coin flips and shuffling
;; Should this element be fancy? 30% chance
(prob/coin 0.3 seed)
;; Shuffle a list in a repeatable way
(prob/shuffle-seeded [1 2 3 4 5] seed)These tools feed naturally into palette sampling, series parameters, and per-item variation — giving you precise artistic control over what would otherwise be pure chance.
Shaped distributions
Beyond uniform and Gaussian, three more distribution shapes for sculpting randomness:
;; Pareto (heavy-tailed): most values small, occasional giants
;; Great for natural size variation (city sizes, star brightness)
(prob/pareto 50 2.0 1.0 seed) ;; 50 values, alpha=2, min=1
;; Triangular: bounded bell curve with explicit min/max/peak
;; "Mostly around 0.3 but never below 0 or above 1"
(prob/sample {:type :triangular :min 0 :max 1 :mode 0.3} seed)
;; Eased: shape any distribution with an easing curve
;; Pass any (fn [t] -> t) to skew the output
(require '[eido.animate :as anim])
(prob/sample {:type :eased :easing anim/ease-in :lo 5 :hi 50} seed)Geometric sampling
Sample points on or inside circles and spheres — useful for scatter patterns, particle emission, and radial layouts:
;; Single point on a circle's circumference
(prob/on-circle 100.0 seed) ;; => [x y]
;; 50 points uniformly inside a disc (no center clustering)
(prob/scatter-in-circle 50 100.0 [200 200] seed)
;; Point on a sphere surface (3D — uniform, no polar clustering)
(prob/on-sphere 10.0 seed) ;; => [x y z]
;; Point uniformly inside a sphere volume
(prob/in-sphere 10.0 [0 0 0] seed) ;; => [x y z]
;; n points on a circle (e.g. for radial layouts)
(prob/scatter-on-circle 12 100.0 [200 200] seed)Jittering point positions
Displace any set of points by a Gaussian offset — turns a regular grid into something organic:
(require '[eido.gen.scatter :as scatter])
;; Break the regularity of a grid
(scatter/jitter (scatter/grid [0 0 400 400] 10 10) {:amount 3.0 :seed seed})Circle Packing
Fill a region with circles of varying sizes, packed tightly without overlapping — like bubbles in a glass or cells under a microscope. It's one of the most visually striking generative techniques and appears constantly in contemporary generative art.
(require '[eido.gen.circle :as circle])
;; Pack circles into a region, color them with a weighted palette
(let [circles (circle/circle-pack [0 0 400 400]
{:min-radius 3 ;; smallest circle
:max-radius 35 ;; largest circle
:padding 2 ;; gap between circles
:max-circles 200 ;; stop after this many
:seed 42}) ;; change for a new arrangement
colors (palette/weighted-sample
(:sunset palette/palettes)
[3 2 2 1 5] (count circles) 42)]
;; Draw each circle with its sampled color
...)
Tweak :min-radius and :max-radius to control the size range. Lower :padding for tighter packing. Increase :max-circles to fill more space.
Packing into shapes
Pack circles inside any closed shape — stars, text outlines, hand-drawn blobs:
;; Pack circles inside a star — each one a different hue
(circle/circle-pack-in-path
(:path/commands (scene/star [200 200] 180 70 5))
{:min-radius 2 :max-radius 15 :seed 42})
The result is always plain data — a list of positions and radii. You choose how to draw them.
Rectangular Subdivision
Start with one big rectangle and split it again and again into smaller cells — like a Mondrian painting, a newspaper layout, or an abstract quilt. Each split chooses a random direction and position, creating organic-looking grids that feel structured but not mechanical.
(require '[eido.gen.subdivide :as sub])
(sub/subdivide [0 0 400 400]
{:depth 4 ;; how many times to split
:min-size 35 ;; don't make cells smaller than this
:split-range [0.3 0.7] ;; how uneven splits can be
:padding 5 ;; gap between cells
:seed 77})
Increase :depth for finer divisions. Widen :split-range (e.g. [0.15 0.85]) for more dramatic size differences. Set :h-bias to 0.0 for only vertical splits or 1.0 for only horizontal.
Each cell knows its :depth — use that to vary color, texture, or content. The real power comes from filling each cell with something different: a circle pack in one, a flow field in another, a flat color in a third.
Weighted Palettes
Real generative art uses color with intention — 60% neutral, 30% primary, 10% accent. Weighted palettes give you explicit control over how often each color appears:
(require '[eido.color.palette :as palette])
;; Sample 100 colors from a palette with weights
;; Neutral dominates, accent is rare
(palette/weighted-sample
[[:color/rgb 240 235 225] ;; neutral — weight 5
[:color/rgb 200 50 50] ;; primary — weight 2
[:color/rgb 50 120 200] ;; secondary — weight 2
[:color/rgb 255 200 0]] ;; accent — weight 1
[5 2 2 1]
100 seed)
The bar chart above shows the sampled colors in order — notice how the neutral cream dominates, while the gold accent appears sparingly. Change the weights to shift the balance.
weighted-gradient creates gradient stops where each color occupies proportional space — feed into gradient-map for smooth interpolation. shuffle-palette randomizes color order with a seed — great for giving each edition a different arrangement from the same palette.
Palette adjustments
Shift an entire palette's mood in one call:
;; Make a palette warmer (shift hues toward orange)
(palette/warmer my-palette 10)
;; Multiple adjustments at once:
(palette/adjust my-palette {:darker 0.1 :muted 0.2})
;; All available: warmer, cooler, muted, vivid, darker, lighterNon-linear gradients
Apply easing functions to gradient interpolation for more natural transitions:
(require '[eido.animate :as anim])
;; Linear gradient (default):
(palette/gradient-map stops 0.5)
;; Ease-in: slow start, fast end
(palette/gradient-map stops 0.5 {:easing anim/ease-in})Palette preview
See a palette instantly at the REPL without building a scene:
;; Preview any palette as a color swatch:
(show (palette/swatch [:red :coral :gold :teal :navy]))
;; Custom dimensions:
(show (palette/swatch my-palette {:width 600 :height 80}))Path Aesthetics
Three helpers that transform any path into something that looks more organic, hand-drawn, or stylized. They work on path commands and return path commands, so you can chain them freely.
Smoothing
Turn angular polylines (gray) into flowing curves (red). The :samples option controls how many points to fit — more points means a tighter fit:
(require '[eido.path.aesthetic :as aesthetic])
(aesthetic/smooth-commands path-cmds {:samples 40})
Jitter — hand-drawn wobble
Add organic irregularity to any path. Control the intensity with :amount — subtle (blue, amount 4) vs. dramatic (red, amount 12):
(aesthetic/jittered-commands path-cmds
{:amount 4.0 ;; displacement intensity
:seed 42}) ;; change seed for different wobble
Dashing
Break a continuous path into dash segments. Three different dash patterns on the same line:
;; Top: 15px on, 8px off
(aesthetic/dash-commands path-cmds {:dash [15.0 8.0]})
;; Middle: long dashes
(aesthetic/dash-commands path-cmds {:dash [30.0 5.0]})
;; Bottom: dots
(aesthetic/dash-commands path-cmds {:dash [5.0 15.0]})
Flow field collision detection
By default, flow field streamlines can cross and overlap. Add :collision-distance to enforce minimum spacing — the result is gallery-ready even density:
(flow/flow-field [0 0 500 400]
{:density 15 :steps 50 :seed 42
:collision-distance 8.0}) ;; streamlines stop when approaching othersCombining them — dashed flow field
The real power is chaining: smooth a flow field, then dash it. Each streamline becomes a series of short, flowing strokes:
(let [paths (flow/flow-field [20 20 460 360]
{:density 30 :steps 35 :seed 42})]
(mapcat (fn [path]
(-> (:path/commands path)
(aesthetic/smooth-commands {:samples 30})
(aesthetic/dash-commands {:dash [10.0 6.0]})))
paths))
Try different :dash ratios, :density values, and :seeds to find the feel you want.
Media presets
Named preset pipelines for common physical media aesthetics — inspect, modify, or combine them:
;; Ink strokes: moderate smoothing + organic jitter
(aesthetic/stylize path-cmds (aesthetic/ink-preset seed))
;; Pencil lines: light smoothing + fine jitter + sketch dashes
(aesthetic/stylize path-cmds (aesthetic/pencil-preset seed))
;; Watercolor edges: heavy smoothing + pronounced bleeding
(aesthetic/stylize path-cmds (aesthetic/watercolor-preset seed))
;; Presets are just data — inspect and modify:
(aesthetic/ink-preset 42)
;; => [{:op :chaikin, :iterations 2}
;; {:op :jitter, :amount 1.2, :density 1.5, :seed 42}]Chaikin smoothing
Chaikin corner-cutting produces rounder, more uniform curves than Catmull-Rom — a different aesthetic feel:
;; Chaikin smoothing (iterative corner-cutting)
(aesthetic/chaikin-commands path-cmds {:iterations 3})
;; Via stylize pipeline:
(aesthetic/stylize cmds [{:op :chaikin :iterations 3}
{:op :dash :dash [10 5]}])Path simplification
Reduce point count while preserving shape — essential for plotter optimization and cleaning up noisy paths:
(require '[eido.path :as path])
;; Douglas-Peucker on raw points (epsilon controls aggressiveness):
(path/simplify [[0 0] [10 1] [20 0] [30 1] [40 0]] 2.0)
;; On path commands:
(path/simplify-commands cmds 2.0)Point-in-polygon
Test whether a point falls inside a polygon — useful for constraining scatter, clipping, and spatial queries:
;; Is the point inside this irregular shape?
(path/contains-point? [[0 0] [100 0] [80 100] [20 80]] [50 50])
;; => truePolygon inset
Shrink a polygon inward — useful for margin control and nested compositions:
;; Shrink a square by 10 pixels on all sides:
(path/inset [[0 0] [100 0] [100 100] [0 100]] 10.0)
;; => [[10 10] [90 10] [90 90] [10 90]]Works correctly for convex polygons. Concave polygons may produce self-intersecting results.
Curve splitting
Divide a path into equal-length segments — essential for plotter optimization and dashed effects:
;; Split a path into segments of ~25px arc-length:
(path/split-at-length path-cmds 25.0)
;; => [[[:move-to [0 0]] [:line-to [25 0]]]
;; [[:move-to [25 0]] [:line-to [50 0]]] ...]Path interpolation
Blend between two matching paths at parameter t:
;; Morph between two shapes (must have same command count):
(path/interpolate path-a path-b 0.5) ;; midpoint between A and BClipping to bounds
Clip a path to a bounding rectangle — useful for constraining generated paths:
;; Clip to a 100x100 rectangle:
(path/trim-to-bounds path-cmds [0 0 100 100])
;; => vector of clipped path-command vectorsLong-Form Series
For Art Blocks / fxhash-style workflows: one algorithm, many outputs, each keyed by an edition number. You define a parameter spec — which values vary and how — and the series module generates independent, deterministic parameters for each edition:
(require '[eido.gen.series :as series])
;; Define what varies across editions
(def spec
{:hue {:type :uniform :lo 0 :hi 360}
:r {:type :gaussian :mean 20 :sd 8}})
;; Generate parameters for editions 0-8
(series/series-range spec master-seed 0 9)
;; Each edition gets different hue and radius values
The grid above shows 9 editions from the same spec — each with a unique hue and size, all derived deterministically from the master seed. Edition 41 and edition 42 are completely uncorrelated despite being neighbors.
Available parameter types: :uniform (even spread), :gaussian (clustered), :choice (pick from a list), :weighted-choice (pick with weights), and :boolean (coin flip with probability).
Trait distribution analysis
Before releasing a series, verify that your trait distribution is what you intended:
(series/trait-summary spec 42 1000
{:density [[30 "sparse"] [70 "medium"] [100 "dense"]]
:speed [[5 "slow"] [10 "fast"]]})
;; => {:density {"sparse" 312, "medium" 398, "dense" 290}
;; :speed {"slow" 487, "fast" 513}}Focused exploration
After a broad sweep, compare favorites side by side:
;; Render specific editions you liked:
(show (series/seed-grid
{:spec spec :master-seed 42
:seeds [12 47 103 256 891]
:scene-fn make-scene :cols 5}))Start wide with :start/:end, find interesting editions, then use :seeds to narrow down.
Parameter sweeps
Isolate a single parameter to see its effect across a range of values:
;; Sweep density across rows, color-count across columns
(show (series/param-grid
{:base-params {:density 0.5 :color-count 3 :seed 42}
:row-param {:key :density :values [0.2 0.5 0.8]}
:col-param {:key :color-count :values [2 3 5]}
:scene-fn (fn [params] (make-scene params))
:thumb-size [160 160]}))param-grid is the complement to seed-grid: where seed-grid varies randomness, param-grid varies design decisions.
Cellular Automata & Reaction-Diffusion
Some of the most mesmerizing organic patterns come from simple rules applied to a grid, over and over. Cells interact with their neighbors, and complex behavior emerges — coral-like growth, dividing cells, rippling waves.
Cellular Automata
The classic Game of Life — and any custom rule set. Start with a random grid, run it forward, and render the result. Each generation, cells are born or die based on how many living neighbors they have:
(require '[eido.gen.ca :as ca])
(let [grid (ca/ca-grid 50 50 :random 42) ;; random starting state
evolved (ca/ca-run grid :life 50)] ;; run 50 generations
(ca/ca->nodes evolved 10 ;; 10px per cell
{:style/fill [:color/rgb 30 30 30]}))
Try :highlife for a different flavor, or define your own rules with {:birth #{3 6} :survive #{2 3}} — specify exactly how many neighbors cause birth or survival.
Reaction-Diffusion
Two invisible chemicals spread across a surface and react with each other, creating organic spots, stripes, and coral-like growth. This is the math behind animal skin patterns and mineral formations. Eido includes named presets so you can jump right in:
;; Grow coral-like patterns from a center seed
(let [grid (ca/rd-grid 80 80 :center 42)
result (ca/rd-run grid (:coral ca/rd-presets) 400)]
(ca/rd->nodes result 5
(fn [a b] ;; a and b are the two chemical concentrations
[:color/rgb
(int (+ 10 (* 80 (min 1.0 (* b 4)))))
(int (+ 20 (* 120 (min 1.0 (* b 4)))))
(int (+ 40 (* 180 (- 1.0 (* a 0.3)))))])))
Presets: :coral (branching growth), :mitosis (dividing cells), :waves (rippling patterns), :spots (leopard-like dots). For animation, call rd-step once per frame.
Boids & Flocking
Ever watched a flock of starlings twist through the sky? Each bird follows three simple rules: don't crowd your neighbors (separation), fly the same direction as them (alignment), and stay close to the group (cohesion). From these three rules, beautiful swirling patterns emerge — no leader, no choreography.
(require '[eido.gen.boids :as boids])
;; Create and simulate a flock
(let [frames (boids/simulate-flock boids/classic 80 {})]
;; Render each frame as oriented triangles
(anim/frames (count frames)
(fn [t]
(let [flock (nth frames (int (* t (dec (count frames)))))]
{:image/size [500 350]
:image/nodes
(boids/flock->nodes flock
{:shape :triangle :size 7
:style {:style/fill [:color/rgb 40 45 55]}})}))))
Presets: boids/classic (balanced, natural flocking) and boids/murmuration (tight starling-like swarming). Add optional behaviors like :seek (steer toward a point), :flee (steer away), or :wander (noise-based drifting) by adding them to the config.
Animation
Animation in Eido is just a sequence of scenes — one per frame. There's no timeline, no keyframe system, no mutable state. You write a function that turns a progress value into a scene, and Eido calls it once per frame to produce a GIF or video.
Creating Animations
An animation in Eido is just a sequence of scenes — one per frame. There's no timeline, no keyframe system, no mutable state. You write a function that takes a progress value t (from 0 to 1) and returns a scene. Eido calls it once per frame:
(require '[eido.animate :as anim])
(def frames
(anim/frames 40 ;; 40 frames total
(fn [t] ;; t goes from 0.0 to 1.0
{:image/size [250 250]
:image/background [:color/rgb 30 30 40]
:image/nodes
[{:node/type :shape/circle
:circle/center [125 125]
:circle/radius (* 90 t) ;; grows over time
:style/fill [:color/hsl (* 360 t) 0.8 0.5]}]})))
;; Render as animated GIF
(eido/render frames {:output "grow.gif" :fps 20})
Since frames are just data, you can manipulate them with all the usual tools — map, filter, concat — to build complex sequences from simple parts.
Easing & Helpers
Easing functions make motion feel natural. Instead of moving at a constant speed (gray), things can accelerate and decelerate smoothly (blue):
(anim/ease-in t) ;; slow start, fast finish
(anim/ease-out t) ;; fast start, slow finish
(anim/ease-in-out t) ;; slow start and finish
(anim/ease-in-cubic t) ;; more dramatic
(anim/ease-out-elastic t) ;; springy overshoot
(anim/ease-out-bounce t) ;; bouncing ball
Other useful helpers:
(anim/ping-pong t) ;; oscillate: 0→1→0
(anim/cycle-n 3 t) ;; repeat 3 times
(anim/lerp 0 100 t) ;; interpolate between values
(anim/stagger 2 5 t 0.3) ;; offset timing per element3D
3D Scenes
Eido can render 3D objects by projecting them onto 2D. You set up a camera (perspective or isometric), define lights, and place 3D meshes in the scene. The result is a regular 2D scene with shaded polygons — no GPU required:
(require '[eido.scene3d :as s3d])
(let [proj (s3d/perspective
{:scale 120 :origin [200 200]
:yaw 0.5 :pitch -0.3 :distance 5})
light {:light/direction [1 1 0.5]
:light/ambient 0.25
:light/intensity 0.8}]
(s3d/sphere proj [0 0 0]
{:radius 1.5
:style {:style/fill [:color/name "cornflowerblue"]}
:light light
:subdivisions 3})) ;; higher = smoother sphere
Available primitives: sphere, cube, cone, torus, cylinder. Load arbitrary meshes from OBJ files with eido.io.obj/load-obj.
Camera types
;; Perspective — objects shrink with distance
(s3d/perspective {:scale 100 :origin [200 200]
:yaw 0.3 :pitch -0.4 :distance 5})
;; Isometric — no perspective distortion
(s3d/isometric {:scale 40 :origin [200 200]})
;; Look-at — point camera at a target
(s3d/look-at {:eye [3 2 5] :target [0 0 0] :up [0 1 0]
:scale 100 :origin [200 200]})Visual Computation
Procedural Fills
Procedural fills evaluate a program expression per pixel over a shape's bounds. The program is pure data — no functions, no macros — just nested vectors describing the computation.
(require '[eido.ir :as ir])
(require '[eido.ir.fill :as fill])
(require '[eido.ir.lower :as lower])
;; A rect filled with noise-driven color
(def scene
(let [semantic
(ir/container [400 400]
{:r 20 :g 20 :b 30 :a 1.0}
[(ir/draw-item
(ir/rect-geometry [0 0] [400 400])
:fill (fill/procedural
{:program/body
[:color/rgb
[:* 255
[:clamp [:+ 0.5
[:* 0.5
[:field/noise
{:field/type :field/noise
:field/scale 4.0
:field/variant :fbm
:field/seed 42}
:uv]]]
0.0 1.0]]
100 200]}))])]
{:ir (lower/lower semantic)}))The program receives :uv as normalized [0..1] coordinates. It can use arithmetic, math functions, field sampling, color construction, and conditional logic.
Expression Language
;; Arithmetic: [:+ a b], [:- a b], [:* a b], [:/ a b]
;; Math: [:abs x], [:sqrt x], [:sin x], [:cos x], [:pow x n]
;; Vectors: [:vec2 x y], [:vec3 x y z]
;; Access: [:x v], [:y v]
;; Mixing: [:mix a b t], [:clamp x lo hi]
;; Conditional: [:select pred a b]
;; Fields: [:field/noise {field-desc} position]
;; Colors: [:color/rgb r g b]Fields
A field is a function over 2D space that returns a value. Fields are reusable descriptors that can be consumed by procedural fills, generators, and programs.
(require '[eido.ir.field :as field])
;; Noise field — wraps eido.gen.noise with configurable parameters
(def f (field/noise-field :scale 0.02 :variant :fbm
:seed 42 :octaves 6))
;; Evaluate at a point
(field/evaluate f 10.0 20.0) ;; => -0.234...
;; Other field types
(field/constant-field 0.5) ;; same value everywhere
(field/distance-field [100 100]) ;; distance from centerNoise Variants
:raw — plain Perlin noise, :fbm — fractal Brownian motion (default), :turbulence — absolute-value fbm, :ridge — ridged multifractal.
Semantic Fills
The semantic IR preserves fill intent as data instead of expanding to geometry immediately. Fill constructors create descriptors that are lowered to concrete drawing operations at render time.
(require '[eido.ir.fill :as fill])
;; Solid color
(fill/solid [:color/rgb 200 50 50])
;; Gradient
(fill/gradient :linear
[[0.0 [:color/rgb 255 0 0]]
[1.0 [:color/rgb 0 0 255]]]
:from [0 0] :to [200 0])
;; Hatch — preserved as semantic data through the pipeline
(fill/hatch {:hatch/angle 45 :hatch/spacing 5
:hatch/color [:color/rgb 0 0 0]})
;; Stipple
(fill/stipple {:stipple/density 0.6 :stipple/radius 2
:stipple/seed 42 :stipple/color [:color/rgb 0 0 0]})
;; Procedural — per-pixel program evaluation
(fill/procedural {:program/body [:color/rgb 255 0 0]})Semantic Effects
Effects are explicit descriptors attached to draw items. They are lowered to buffer compositing operations at render time.
(require '[eido.ir :as ir])
(require '[eido.ir.effect :as effect])
(require '[eido.ir.fill :as fill])
(ir/draw-item
(ir/rect-geometry [50 50] [200 150])
:fill (fill/solid [:color/rgb 60 120 200])
:effects [(effect/shadow :dx 5 :dy 5 :blur 10
:color [:color/rgb 0 0 0]
:opacity 0.5)
(effect/glow :blur 12
:color [:color/rgb 100 200 255]
:opacity 0.6)])Filter Effects
Filter effects apply image-space processing: blur, grain, posterize, duotone, halftone.
(effect/grain :amount 40 :seed 42)
(effect/posterize :levels 4)
(effect/duotone :color-a [:color/rgb 20 20 60]
:color-b [:color/rgb 255 230 180])
(effect/halftone :dot-size 8 :angle 45)Transforms
Semantic transforms modify geometry before rendering — distortion, warping, and path morphing.
(require '[eido.ir.transform :as transform])
;; Noise distortion on a path
(ir/draw-item
(ir/path-geometry [[:move-to [0 100]] [:line-to [200 100]]])
:fill (fill/solid [:color/rgb 0 0 0])
:pre-transforms [(transform/distortion :noise
{:amplitude 10 :frequency 0.05 :seed 42})])
;; Warp a rect with wave deformation
(ir/draw-item
(ir/rect-geometry [20 20] [160 160])
:fill (fill/solid [:color/rgb 100 150 200])
:pre-transforms [(transform/warp-transform :wave
{:axis :y :amplitude 15 :wavelength 40})])
;; Morph between two paths
(transform/morph-transform target-path 0.5)Generators
Generators produce geometry procedurally — flow fields, contours, scatter distributions, Voronoi tessellation, decorators, and particles.
(require '[eido.ir.generator :as gen])
;; Flow field from noise
(gen/flow-field [0 0 400 300]
:opts {:density 20 :steps 50 :seed 42}
:style {:stroke {:color [:color/rgb 0 0 0] :width 1}})
;; Contour lines at thresholds
(gen/contour [0 0 400 300]
:opts {:thresholds [-0.2 0.0 0.2] :resolution 5})
;; Scatter shapes at positions
(gen/scatter-gen shape-node [[50 50] [150 150]]
:overrides (vary/by-gradient 2 [[0 red] [1 blue]]))
;; Voronoi tessellation
(gen/voronoi-gen points [0 0 400 300]
:style {:stroke {:color [:color/rgb 0 0 0] :width 1}})
;; Particle snapshot at frame 30
(gen/particle-gen fire-config 30 60)3D Materials
Material descriptors add specular highlights to 3D meshes using Blinn-Phong shading.
(require '[eido.ir.material :as material])
(require '[eido.scene3d :as s3d])
;; Phong material with specular highlights
(s3d/render-mesh projection mesh
{:style {:style/fill [:color/rgb 150 100 200]
:material (material/phong
:specular 0.4
:shininess 32.0)}
:light {:light/direction [1 2 1]
:light/ambient 0.2
:light/intensity 0.8}})Light Types
;; Directional — parallel rays (like the sun)
(material/directional [1 2 1] :multiplier 0.8 :ambient 0.2)
;; Omni — point light radiating in all directions
(material/omni [100 50 200]
:color [:color/rgb 255 200 150]
:decay :inverse-square :decay-start 10.0)
;; Spot — cone with hotspot/falloff angles
(material/spot [0 200 0] [0 -1 0]
:hotspot 25 :falloff 35 :decay :inverse)
;; Hemisphere — sky/ground ambient
(material/hemisphere
[:color/rgb 135 180 220] [:color/rgb 40 30 20]
:multiplier 0.3)Multi-Light
Use :lights to combine multiple lights. Each light's color tints its contribution.
(s3d/render-mesh proj mesh
{:style {:style/fill [:color/rgb 200 200 200]
:material (material/phong :specular 0.5)}
:lights [(material/omni [3 2 2]
:color [:color/rgb 255 180 100]
:multiplier 1.5 :decay :inverse)
(material/hemisphere
[:color/rgb 40 50 80] [:color/rgb 15 10 5]
:multiplier 0.2)]})Multi-Pass Rendering
Pipelines chain multiple passes — draw geometry, then apply effects.
(require '[eido.ir :as ir])
(require '[eido.ir.effect :as effect])
;; Draw shapes, then apply grain to the whole image
(ir/pipeline [400 400]
background
[{:pass/id :draw :pass/type :draw-geometry
:pass/items [rect-item circle-item]}
(ir/effect-pass :grain (effect/grain :amount 30 :seed 42))])Domains
A domain describes the coordinate space over which a program or field is evaluated. Domains declare what bindings are available in the evaluation environment.
(require '[eido.ir.domain :as domain])
;; Image grid — pixel coordinates with UV
(domain/image-grid [800 600])
;; Bindings: :uv [0..1, 0..1], :px, :py, :size
;; World 2D — scene coordinates within bounds
(domain/world-2d [0 0 400 300])
;; Bindings: :pos [x y], :x, :y
;; Other domains: shape-local, path-param, mesh-faces,
;; points, particles, timelinePrograms with a :program/domain validate that the evaluation environment provides the expected bindings.
Resources
Resources are named objects that passes read and write. They make multi-pass data flow explicit.
(require '[eido.ir.resource :as resource])
;; Declare resources
(resource/image :buffer [400 300])
(resource/mask :alpha-mask [400 300])
(resource/parameter-block :params {:time 0.5 :seed 42})
;; Pipeline with explicit resources
(ir/pipeline [400 300] background
[{:pass/id :draw :pass/type :draw-geometry
:pass/target :framebuffer :pass/items [...]}
{:pass/id :process :pass/type :effect-pass
:pass/input :framebuffer :pass/target :output
:pass/effect (effect/grain :amount 30)}]
{:resources (resource/image :output [400 300])})
;; Validate that all passes reference declared resources
(resource/validate-pipeline-resources pipeline)Recipes
Long-Form Edition
The canonical workflow for seed-driven generative art (Art Blocks / fxhash style). One algorithm, many unique outputs.
(require '[eido.gen.series :as series])
(require '[eido.gen.prob :as prob])
(require '[eido.core :as eido])
;; 1. Define what varies across editions
(def spec
{:hue {:type :uniform :lo 0.0 :hi 360.0}
:density {:type :gaussian :mean 20.0 :sd 5.0}
:palette {:type :choice :options [:sunset :ocean :forest]}
:bold? {:type :boolean :probability 0.3}})
;; 2. Build a scene from sampled parameters
(defn make-scene [params edition]
{:image/size [800 800]
:image/background [:color/hsl (:hue params) 0.15 0.95]
:image/nodes
[{:node/type :shape/circle
:circle/center [400 400]
:circle/radius (* 300 (/ (:density params) 40.0))
:style/fill [:color/hsl (:hue params) 0.7 0.5]}]})
;; 3. Render a batch with metadata
(series/render-editions
{:spec spec :master-seed 42
:start 0 :end 50
:scene-fn make-scene
:output-dir "editions/"
:traits {:density [[15 "sparse"] [25 "medium"] [100 "dense"]]}})Each edition gets a deterministic, uncorrelated seed. The same master-seed + edition-number always produces the same output. The metadata.edn file records parameters and derived traits for every edition.
Watercolor / Ink Wash
Simulate translucent media by layering many low-opacity, slightly deformed copies of a shape. The overlapping layers create organic depth.
(require '[eido.texture :as texture])
;; Quick watercolor effect on a shape:
(texture/watercolor my-polygon
{:layers 30 :opacity 0.04 :amount 3.0 :seed 42})
;; Full control with custom deformation:
(texture/layered my-polygon
{:layers 40 :opacity 0.03 :seed 42
:deform-fn (fn [node _layer-index seed]
(update node :path/commands
distort/distort-commands {:type :jitter :amount 4 :seed seed}))})Each layer is independently deformed, creating the characteristic uneven edge of physical watercolor. 30-50 layers works well for interactive use; up to 100 for final renders.
Paper Grain / Texture
Simulate the texture of physical media (watercolor paper, canvas) using the built-in grain effect and compositing:
(require '[eido.ir.effect :as effect])
;; Paper grain overlay — applies to any artwork:
{:image/size [600 400]
:image/background [:color/rgb 245 240 230]
:image/nodes
[{:node/type :group
:group/composite :overlay
:group/children
[;; Paper texture layer
{:node/type :shape/rect
:rect/xy [0 0] :rect/size [600 400]
:style/fill [:color/rgb 245 240 230]
:node/effects [(effect/grain {:amount 40 :seed 42})]}
;; Your artwork goes here
,,,your nodes,,,]}]}The grain effect adds film-grain noise to a shape. Compositing via :overlay or :multiply blends it with the artwork underneath, simulating paper texture.
Subdivide → Pack → Stylize
A recipe for geometric abstraction: subdivide the canvas, pack circles into cells, assign colors by depth.
(require '[eido.gen.subdivide :as subdivide])
(require '[eido.gen.circle :as circle])
(require '[eido.color.palette :as palette])
;; 1. Subdivide canvas into cells
(let [cells (subdivide/subdivide
{:x 0 :y 0 :w 800 :h 800}
{:max-depth 4 :min-size 80 :seed 42})
pal (:sunset palette/palettes)]
;; 2. Pack circles into each cell
(->> cells
(mapcat (fn [{:keys [x y w h depth]}]
(let [circles (circle/circle-pack
{:x x :y y :w w :h h}
{:min-r 3 :max-r (/ (min w h) 4)
:max-attempts 500 :seed (hash [x y])})]
;; 3. Style by subdivision depth
(map (fn [c]
{:node/type :shape/circle
:circle/center [(:x c) (:y c)]
:circle/radius (:r c)
:style/fill (nth pal (mod depth (count pal)))
:style/stroke {:color :black :width 0.5}})
circles))))
vec))This pattern composes three modules (subdivide, circle, palette) into a single visual method. Swap the subdivision for noise-driven regions, or replace circles with hatched rectangles — the structure stays the same.
Flow Field → Path → Texture
Organic line work from noise fields: trace streamlines, smooth them, add texture.
(require '[eido.gen.noise :as noise])
(require '[eido.gen.flow :as flow])
(require '[eido.path.aesthetic :as aes])
;; 1. Build a flow field from noise
(let [field (flow/flow-field
{:bounds [0 0 800 800]
:resolution 20
:noise-fn (fn [x y] (noise/fbm x y {:octaves 4 :scale 0.003}))})
;; 2. Trace streamlines
paths (:paths field)]
;; 3. Smooth and jitter each path
(->> paths
(map (fn [path-cmds]
{:node/type :shape/path
:path/commands (-> path-cmds
(aes/smooth-commands {:tension 0.4})
(aes/jittered-commands {:amount 1.5 :seed 42}))
:style/stroke {:color :black :width 0.8}}))
vec))For plotter output, render with {:stroke-only true :group-by-stroke true} to get clean, single-pen SVG layers.
CA / Reaction-Diffusion → Contour → Palette
Field-driven biological abstraction: simulate, extract contours, map to color.
(require '[eido.gen.ca :as ca])
(require '[eido.gen.contour :as contour])
(require '[eido.color.palette :as palette])
;; 1. Run Gray-Scott reaction-diffusion
(let [grid (ca/rd-grid 200 200)
result (ca/rd-run grid 3000 {:preset :coral})
;; 2. Extract concentration field as a scalar function
field-fn (fn [x y]
(let [col (int (/ (* x 200) 800))
row (int (/ (* y 200) 800))]
(aget ^doubles (nth (:v result) (min row 199))
(min col 199))))
;; 3. Contour at threshold levels
pal (:ocean palette/palettes)
levels [0.1 0.2 0.3 0.4 0.5]]
(->> levels
(map-indexed
(fn [i level]
(let [contours (contour/contour-lines
{:bounds [0 0 800 800]
:fn field-fn
:level level
:resolution 4})]
{:node/type :group
:group/children
(mapv (fn [path-cmds]
{:node/type :shape/path
:path/commands path-cmds
:style/fill (nth pal (mod i (count pal)))
:style/stroke {:color :black :width 0.3}})
contours)})))
vec))The reaction-diffusion presets (:coral, :mitosis, :ripple, :spots) each produce distinctive field patterns. Contour extraction turns continuous fields into drawable regions.
Paint Engine
The paint engine renders brushstrokes as pixel-level dab sequences onto a tiled raster surface. Everything is procedural — no bitmap textures. Brushes are data, strokes are data, and paint composes naturally with generators like flow fields and scatter.
Basics
The simplest way to use paint is the standalone :paint/surface node. Define strokes with explicit point data including pressure:
{:node/type :paint/surface
:paint/size [600 400]
:paint/strokes
[{:paint/brush :chalk
:paint/color [:color/rgb 80 60 40]
:paint/radius 12.0
:paint/points [[50 100 0.8 0 0 0] ;; [x y pressure speed tilt-x tilt-y]
[300 60 1.0 1.0 0 0]
[550 100 0.3 0.5 0 0]]}]}
Each point carries six values: x, y, pressure, speed, tilt-x, tilt-y. Pressure modulates radius and opacity along the stroke.
Brush presets
36 built-in presets cover dry media, ink, markers, paint, tools, and effects. A few common ones:
:chalk ;; dry, textured with jitter
:ink ;; hard, high opacity
:watercolor ;; wet diffusion, granulation
:oil ;; smudge, color mixing
:charcoal ;; soft with heavy grain
:flat-marker ;; glazed rectangular tip
:brush-pen ;; calligraphic
:impasto ;; thick paint with heightOverride any preset parameter:
{:paint/brush {:brush/type :brush/dab
:brush/tip {:tip/shape :ellipse :tip/hardness 0.9}
:brush/paint {:paint/opacity 0.7 :paint/spacing 0.03}}}Painted Paths
Add :paint/brush to any path node to render it as a painted stroke instead of a vector shape:
{:node/type :shape/path
:path/commands [[:move-to [50 100]]
[:curve-to [150 30] [350 170] [550 100]]]
:paint/brush :chalk
:paint/color [:color/rgb 80 60 40]
:paint/radius 12.0
:paint/pressure [[0.0 0.3] [0.5 1.0] [1.0 0.1]]}
:paint/pressure is a [[t pressure] ...] curve where t goes from 0 (start) to 1 (end). Pressure scales both radius and opacity.
Shared Surfaces
When multiple strokes need to interact (smudge, wet mixing), wrap them in a group with :paint/surface:
{:node/type :group
:paint/surface {:substrate/tooth 0.4}
:group/children
[{:node/type :shape/path
:path/commands [...]
:paint/brush :oil
:paint/color [:color/rgb 200 60 30]}
{:node/type :shape/path
:path/commands [...]
:paint/brush :oil
:paint/color [:color/rgb 50 100 200]}]}All painted children render onto the same raster surface, so later strokes can interact with earlier ones.
Composing with Generators
Paint parameters propagate through generators. This means you can paint flow fields, scatter patterns, and symmetry groups:
{:node/type :group
:paint/surface {:paint/size [600 600]}
:group/children
[{:node/type :flow-field
:flow/bounds [30 30 540 540]
:flow/opts {:density 20 :steps 40 :seed 77}
:paint/brush :ink
:paint/color [:color/rgb 15 12 8]
:paint/radius 2.0}]}
The flow field generates paths, and each path is rendered as a painted stroke with the ink brush. The same approach works with :scatter, :symmetry, and other generators.
Stroke Texture (Jitter)
Per-dab variation creates realistic brush-mark texture. Add :brush/jitter to any brush spec:
{:brush/jitter {:jitter/position 0.15 ;; random X/Y offset (fraction of radius)
:jitter/opacity 0.25 ;; per-dab opacity variation
:jitter/size 0.1 ;; per-dab radius variation
:jitter/angle 0.15}} ;; random angle offsetPresets like :chalk, :pastel, :oil, and :watercolor include default jitter. All jitter is deterministic — set :paint/seed for reproducible results.
Buildup Modes
Control how paint accumulates within a stroke via :paint/blend in the brush paint spec:
;; Glazed — prevents over-saturation (markers, ink wash)
{:brush/paint {:paint/blend :glazed ...}}
;; Opaque — thick coverage (oil, acrylic, gouache)
{:brush/paint {:paint/blend :opaque ...}}
;; Erase — removes existing paint
{:brush/paint {:paint/blend :erase ...}}For thick paint with visible height, add :brush/impasto:
{:brush/impasto {:impasto/height 0.6}} ;; simulates raised paintSpatter and Drip
Speed-driven particle emission creates spatter, drip, and spray effects:
{:brush/spatter {:spatter/threshold 0.3 ;; speed above which spatter activates
:spatter/density 0.5 ;; particles per dab
:spatter/spread 3.0 ;; distance in radii
:spatter/size [0.03 0.2] ;; [min max] fraction of brush radius
:spatter/opacity [0.2 0.7] ;; [min max]
:spatter/mode :scatter}} ;; :scatter or :sprayModes: :scatter (perpendicular to stroke), :spray (cone along stroke direction).
Tool Presets
36 built-in presets organized by family:
;; Dry media: :pencil :graphite :charcoal :conte :chalk
;; :pastel :soft-pastel :crayon
;; Ink & pen: :ink :ballpoint :felt-tip :fountain-pen
;; :brush-pen :technical-pen
;; Marker: :marker :flat-marker :chisel-marker :highlighter
;; Wet paint: :watercolor :gouache :acrylic-wash
;; Thick paint: :oil :acrylic :impasto :tempera
;; Tools: :smudge-tool :palette-knife :eraser :blender
;; Effects: :airbrush :spray-paint :splatter
;; Deform: :push :swirl :blur-tool :sharpen-toolEach preset includes appropriate jitter, grain, buildup mode, and interaction settings for realistic default behavior.
Deform Tools
Deform brushes modify existing pixels without depositing paint:
;; Push pixels along stroke direction
{:paint/brush :push :paint/radius 20.0}
;; Swirl pixels around dab center
{:paint/brush :swirl :paint/radius 25.0}
;; Blur existing paint
{:paint/brush :blur-tool :paint/radius 15.0}
;; Sharpen edges
{:paint/brush :sharpen-tool :paint/radius 12.0}These work on shared surfaces — lay down paint first, then deform it.
UX Helpers
Convenience functions for programmatic artists without physical input devices:
(require '[eido.paint :as paint])
;; Auto-derive pressure from path geometry
(paint/auto-pressure points {:mode :taper}) ;; bell-shaped
(paint/auto-pressure points {:mode :curvature}) ;; tight curves = pressure
(paint/auto-pressure points {:mode :speed}) ;; fast = lighter
;; Auto-derive speed curve
(paint/auto-speed points)
;; Named dynamics profiles
(paint/dynamics-profile :calligraphy) ;; or :expressive :steady :feathered :bold
;; One-call stroke creation with auto-derived pressure
(paint/stroke-from-path path-commands
{:brush :chalk :color [:color/rgb 60 40 30] :radius 12
:dynamics :calligraphy})Output
Export
Everything goes through one function — eido/render. The output format is determined by the file extension:
;; Static images
(eido/render scene {:output "out.png"}) ;; PNG (default)
(eido/render scene {:output "out.svg"}) ;; SVG (vector)
(eido/render scene {:output "out.jpg" :quality 0.9})
(eido/render scene {:output "out.tiff" :dpi 300}) ;; archival TIFF
;; Animations
(eido/render frames {:output "anim.gif" :fps 30})
(eido/render frames {:output "anim.svg" :fps 30}) ;; animated SVG
(eido/render frames {:output "frames/" :fps 30}) ;; PNG sequenceOptions
:scale 2 ;; 2x resolution (retina)
:dpi 300 ;; DPI metadata (PNG/TIFF)
:transparent-background true ;; no background fill
:loop false ;; GIF plays once (default: loops)
:tiff/compression :lzw ;; TIFF: :lzw (default), :deflate, :none
:quality 0.9 ;; JPEG quality (0-1)Render without an output path to get a BufferedImage back for further processing, or use :format :svg to get an SVG string.
If the scene includes :image/dpi, PNG and TIFF output automatically embed DPI metadata — no need to pass :dpi separately.
Print-Ready Output
Artists producing physical output — prints, plotter work, fine art editions — need to think in centimeters or inches, not pixels. Eido provides resolution-independent coordinates and paper size presets.
Paper size presets
(require '[eido.scene :as scene])
;; Standard paper sizes — returns a base scene map
(scene/paper :a4)
;=> {:image/size [21.0 29.7] :image/units :cm :image/dpi 300}
(scene/paper :letter :landscape true)
;=> {:image/size [11.0 8.5] :image/units :in :image/dpi 300}
(scene/paper :a3 :dpi 600)
;=> {:image/size [29.7 42.0] :image/units :cm :image/dpi 600}
;; Available sizes: :a3 :a4 :a5 :letter :legal :tabloid :square-8Unit conversion
scene/with-units converts a scene described in real-world units to pixel coordinates. It walks the entire scene tree, scaling all spatial values (coordinates, radii, stroke widths, dash patterns, font sizes) while leaving non-spatial values (opacity, angles, colors) untouched:
;; Describe your scene in centimeters
(-> (scene/paper :a4)
(assoc :image/background :white
:image/nodes
[{:node/type :shape/circle
:circle/center [10.5 14.85] ;; center of A4 in cm
:circle/radius 5.0 ;; 5 cm radius
:style/stroke {:color :black :width 0.1}}]) ;; 1mm stroke
scene/with-units ;; converts to pixels
(eido/render {:output "print.tiff"}))
;; Output: 2480×3508 px TIFF at 300 DPI with embedded DPI metadataSupported units: :cm (centimeters), :mm (millimeters), :in (inches).
with-units is a pure function — it takes a data map and returns a data map. You can inspect the converted scene at the REPL before rendering.
Polyline Data Export
For CNC mills, laser cutters, and custom plotter software, raw coordinate data is more useful than rendered images. Use :format :polylines to extract geometry as EDN:
;; Extract polylines from any scene
(eido/render scene {:format :polylines})
;=> {:polylines [[[x1 y1] [x2 y2] ...] ...]
; :bounds [800 600]}
;; Write to file
(eido/render scene {:format :polylines :output "paths.edn"})
;; Control curve resolution
(eido/render scene {:format :polylines :flatness 0.5 :segments 64})All geometry is converted to polylines: curves are flattened via de Casteljau subdivision, circles and ellipses are approximated as polygons. Groups are recursively traversed.
Options: :flatness controls curve subdivision tolerance (default 0.5, lower = more points). :segments controls circle/ellipse polygon resolution (default 64).
Plotter-Safe SVG
For pen plotters, use the plotter options to produce clean, stroke-only SVG:
;; Stroke-only: removes all fills, suppresses background
(eido/render scene {:output "plotter.svg" :stroke-only true})
;; Group by stroke color: one <g> per pen/color
(eido/render scene {:output "plotter.svg"
:stroke-only true
:group-by-stroke true})
;; Full plotter pipeline: deduplicate, optimize pen travel
(eido/render scene {:output "plotter.svg"
:stroke-only true
:group-by-stroke true
:deduplicate true
:optimize-travel true})With :group-by-stroke, each stroke color gets its own <g> element with an id like pen-rgb-0-0-0. Load the SVG in your plotter software and assign each group to a pen.
:deduplicate removes identical paths (common with overlapping geometry). :optimize-travel reorders drawing operations to minimize pen-up travel distance using greedy nearest-neighbor — can significantly reduce total plot time.
Batch Edition Rendering
Render many editions at once with series/render-editions:
(require '[eido.gen.series :as series])
(series/render-editions
{:spec {:hue {:type :uniform :lo 0 :hi 360}}
:master-seed 42
:start 0
:end 100
:scene-fn (fn [params edition] (make-scene params))
:output-dir "editions/"
:format :png ;; or :svg
:render-opts {:scale 2} ;; passed to eido/render
:traits {:hue [[120 "cool"] [240 "warm"] [360 "hot"]]}})This writes one file per edition plus a metadata.edn containing the parameter values and derived traits for every edition.
File Workflow
Scenes can be stored as .edn files and loaded for rendering:
(eido/render (eido/read-scene "my-scene.edn") {:output "out.png"})
;; Watch a file — auto-reload the preview on every save
(watch-file "my-scene.edn")
;; Watch an atom for live coding
(def my-scene (atom {...}))
(watch-scene my-scene)
;; tap> integration — render by tapping
(install-tap!)
(tap> {:image/size [200 200] :image/nodes [...]})Validation
Scenes are validated before rendering. If something is wrong, you get a clear error pointing to the exact problem:
(eido/validate {:image/size [800 600]
:image/background [:color/rgb 255 255 255]
:image/nodes [{:node/type :shape/rect}]})
;; => [{:path [:image/nodes 0]
;; :pred "missing required key :rect/xy" ...}]For a quick overview at the REPL, use explain to print formatted errors:
(eido/explain {:image/size [800 600]
:image/background [:color/rgb 255 0]
:image/nodes [{:node/type :shape/polygon}]})
;; 2 validation errors:
;;
;; 1. at [:image/background]: integer in 0..255, got: ()
;;
;; 2. at [:image/nodes 0]: unknown node type; valid types are:
;; :group, :shape/arc, :shape/circle, ...You can also format error data with format-errors:
(eido/format-errors (eido/validate scene))Invalid scenes throw ex-info with :errors in the exception data and a human-readable message, so you always know what went wrong.
Validation in the REPL
The dev helpers show, watch-file, and watch-scene validate the first render, then skip validation on subsequent re-renders for faster iteration. This gives you error checking when starting up while keeping the feedback loop fast.
To control validation explicitly, bind eido/*validate*:
;; Skip validation for fast re-renders
(binding [eido/*validate* false]
(eido/render scene))
;; Or disable per-scene with a key
(eido/render (assoc scene :eido/validate false))*validate* defaults to true. Validation adds roughly 7% overhead per render, so skipping it in tight iteration loops makes a noticeable difference.
Stability
Functions in Eido have one of two stability levels:
- Stable (default) — the function signature and behavior are settled. Breaking changes require a major version bump and migration guidance.
- Provisional — the function works and is tested, but the API surface may change based on real-world usage. Provisional functions are marked with a badge in the API Reference.
Most of Eido's API is stable. Provisional status is reserved for newer subsystems whose configuration surface is still being refined:
eido.gen.particle— particle system configuration (emitters, forces, lifetime curves) may simplify as usage patterns emerge.eido.texture— texture and material helpers are new and may expand or restructure.eido.paint— paint engine brush specs, stroke parameters, and surface configuration are being refined.
Provisional does not mean broken — it means function names, argument shapes, or option keys might change in a future release. Pin a specific Eido version in your deps.edn to avoid surprises.