Pulliv0.1
8px
06 — Generator

The pattern layer is computed, not drawn.

Kolams decompose into three tiles. Constrain them with a symmetry group, close the boundary with loops around the outer dots, and the output is always a valid, continuous kolam. This is the asset pipeline: brand marks, empty states, loaders and print borders all come out of one function, and are committed as five numbers rather than as files.

Try it

Every control maps directly to a parameter of Kolam.generate(). The call that produced what you are looking at is printed below the panel.

Kolam.generate({ dots: 7, symmetry: 'd4', crossing: 0.2, seed: 1729 })

The tile set

Three tiles. That is the entire vocabulary.

The unit of composition is not the dot but the square cell bounded by four dots. Each cell carries one tile, and every tile connects only at the midpoints of the cell's four edges.

tile A — quarter arcs around the top-left and bottom-right dots
tile B — quarter arcs around the top-right and bottom-left dots
tile C — a crossing, kambi over kambi
Why any tile joins any other. Because the arc radius is exactly P/2, an arc's endpoints land on edge midpoints, and its tangent there is perpendicular to the edge. Tile C's straight lines are perpendicular to their edges too. So whatever two tiles are adjacent, the curves meet at the same point with the same tangent and the line stays smooth and unbroken. No adjacency rules, no backtracking, no failure cases.

This is the same property the token model depends on. If r were anything other than P/2, tiles would not compose and corner radii would not agree with padding. One constraint, two payoffs.

Algorithm

Five steps, no search, no backtracking. Linear in the number of cells.

01

Lay the pulli. An n × n lattice, n odd so the kolam has a centre dot. Cells number m = n − 1 per side, always even.

02

Seed one quadrant. Fill the top-left ⌈m/2⌉ × ⌈m/2⌉ block by drawing from the tile set: crossing with probability c, otherwise A or B evenly. For D4, copy the transpose across the quadrant's diagonal.

03

Mirror it out. Reflect the quadrant horizontally and vertically. Each reflection swaps A ↔ B; C is invariant. That swap is what preserves the geometry — a mirrored arc curls around the opposite pair of dots.

04

Emit tile paths. Two arcs or two straight lines per cell, at fixed positions relative to the cell origin.

05

Close the boundary. Pair the open perimeter ends into loops around the outer dots. See below — this is the only step with a proof obligation.

function generate({ dots, symmetry, crossing, seed }) {
  const m     = dots - 1;
  const rand  = mulberry32(seed);
  const tiles = makeTiles(m, symmetry, crossing, rand);

  let paths = [];
  for (let i = 0; i < m; i++)
    for (let j = 0; j < m; j++)
      paths.push(...tilePaths(i, j, tiles[i][j]));

  paths.push(...boundaryPaths(m));
  return { svg: toSVG(paths), paths, meta: {...} };
}
Determinism. The RNG is mulberry32, seeded explicitly. The same seed produces the same kolam on every machine, every browser, forever. A pattern in a repository is five numbers in a config file, not a binary asset that has to be regenerated and re-reviewed.

Symmetry groups

Same seed, same crossing probability, three constraints. The vocabulary matches the layout rules in principle 03 deliberately.

d4 — invariant under both mirrors and the transpose. Eight symmetries. Reads as resolved and formal; the right choice for a mark or a repeating ground.

d2 — horizontal and vertical mirrors only. Four symmetries. More movement, still balanced; good for banners and wide crops.

free — unconstrained. Reads as texture rather than as a figure. Use where the pattern must not compete with content.

Why mirrors swap the tiles

Tile A places its arcs around the top-left and bottom-right dots — the leading diagonal. Reflect the cell horizontally and those arcs now sit on the counter-diagonal, which is tile B by definition. Transpose, however, maps top-left to top-left and bottom-right to bottom-right, so it preserves the type. Getting this wrong is the single most common way a kolam generator produces patterns that look symmetric but have broken lines.

// a mirror swaps the two arc orientations; a crossing is invariant
function flip(v) { return v === 2 ? 2 : (v === 0 ? 1 : 0); }

Closing the boundary

The only step that could fail — so it is the step with an argument attached.

Tiles on the outer ring leave open ends on the perimeter. If those are left dangling, the result is not a closed kolam: principle 02 forbids a broken line, and the generator has to be the first thing that obeys it.

Walk the perimeter clockwise, collecting ends. There are m per side, so 4m in total, listed in spatial order. Pair them with an offset of one — that is, pair index 1 with 2, 3 with 4, and finally 4m−1 with 0, wrapping.

Two cases arise. Both ends on the same side are P apart with a dot exactly between them: join them with a half-loop of radius P/2 centred on that dot, bulging outward. Ends that straddle a corner are both P/2 from the corner dot at ninety degrees: join them with a three-quarter loop around it.

Why it always closes. n is odd, so m = n − 1 is even. With an even count per side, the offset pairing yields exactly four corner joins and (m−2)/2 same-side joins per side — 2m loops in total, every end consumed exactly once. There is no residue and no special case for small grids: a 3×3 kolam is four corner loops and nothing else.

m = 2 · four corner loops, no same-side joins

m = 6 · four corner loops + two half-loops per side

Verified, not asserted. The test suite counts path endpoints across every grid size from 3 to 15, all three symmetry groups, and crossing probabilities from 0 to 0.7. Every endpoint must be shared by exactly two paths. It also checks that every arc's centre lies on a lattice dot, and that the D4 tile grid is invariant under transpose and both mirrors.

API reference

assets/kolam.js — no dependencies, UMD, 4 KB.

Kolam.generate(options)

OptionTypeDefaultNotes
dotsnumber7Dots per side. Forced odd — a kolam needs a centre dot. Practical range 3–15.
symmetry"d4" | "d2" | "none""d4"Constraint applied to the tile grid.
crossingnumber 0–10.2Probability a cell is a crossing rather than a pair of arcs. Above ~0.5 the pattern reads as a weave rather than a kolam.
strokenumber3Kambi weight in SVG user units (lattice unit is 40).
showDotsbooleantrueRender the pulli themselves.
seednumber | nullnullInteger for deterministic output; null draws a random one and reports it in meta.
titlestring | nullnullAccessible name on the SVG. Auto-generated when omitted.

Returns { svg, paths, meta }. meta carries dots, cells, seed, symmetry, crossing, segments, boundaryLoops, unit, radius — enough to reproduce the exact output.

Other methods

MethodPurpose
Kolam.render(el, opts, animate)Generate and inject into an element. Returns meta. Skips animation automatically under prefers-reduced-motion.
Kolam.drawOn(el)Run the draw-on animation over an already-rendered kolam.
Kolam.toStandaloneSVG(opts, colors)SVG string with colours inlined and explicit dimensions, for export or embedding outside the token system.
Kolam.tilePaths(i, j, type)Path data for one cell. Exposed for documentation and testing.

Declarative rendering

With site.js loaded, any element carrying data-kolam is rendered on load. Add data-animate to draw it on.

<div data-kolam='{"dots":9,"seed":2024,"symmetry":"d4"}' data-animate></div>

Asset recipes

Parameters that reliably produce a given kind of asset. Treat as starting points, not rules.

Assetdotssymmetrycrossingstrokedots shown
Brand mark / favicon3–5d405–6no
Empty state illustration5–7d40.153yes
Loading indicator5d404no
Hero / page ground11–15none0.32no
Print border7–9d20.14yes
Card media5d40.34no

Loading indicator

The draw-on animation is the loader. It is decorative by definition, so it is removed entirely under reduced motion — replaced by a static kolam plus a text status, never by a shortened version of the same animation.

const el = document.querySelector('.loader');
Kolam.render(el, { dots: 5, crossing: 0, stroke: 4, showDots: false }, true);

/* accessibility: the visual is decoration, the status is text */
<div class="loader" aria-hidden="true"></div>
<p role="status">Loading invoices…</p>
Density limit applies to every recipe. A hero ground at dots: 15 is legitimate at 6% opacity behind a solid content panel, and illegitimate at full strength behind body text. See Accessibility → the pattern layer.

Performance & limits

dotscellspath elements
32 × 212
76 × 684
1110 × 10220
1514 × 14420

Segments = 2m² + 2m. Generation is well under a millisecond at every size; the cost is DOM nodes, not computation.

  • Above dots: 15, prefer a single merged path or a rasterised export. The visual gain is marginal and the node count is not.
  • Draw-on animation transitions stroke-dashoffset per segment. Beyond ~200 segments, stagger in groups rather than per path, or skip the animation.
  • Repeating grounds should be generated once and applied as a CSS background, not repeated in the DOM.
  • Server-side works unchanged — the module is UMD and touches no browser API inside generate().