Book › Developers › Writing a symmetry tool

Writing a symmetry tool

A symmetry mode is a plugin: one class extending SymmetryTool (with an icon and a create()), plus a one-line row in symmetry.json - the same shape as brushes.

What a symmetry tool is

Symmetry is a global tool that repeats any brush. A tool's whole job is to turn one stroke into a list of affine copies (Transform[]). A proxy around the drawing surface then replays every mark and every deposited point at each transform, so the connecting web threads across the pattern, not just one copy. Tile, Radial, Mirror, Concentric and Spiral all live in src/symmetry/tools/ - one file each.

A minimal tool

Extend SymmetryTool, implement transforms(), and (optionally) declare panel controls and guide lines. Here is an "Echo" that stamps fading offset copies of the stroke:

// src/symmetry/tools/echo.ts
import { SymmetryTool, type SymSetting, type ToolContext } from "../tool";
import type { Transform } from "../transforms";

export const icon = "…svg markup…";

class EchoTool extends SymmetryTool {
  // Copies are a fixed offset from the stroke, so no centre is needed.
  // (Set `usesCentre = true` to get the shared Centre sliders + ctx.cx/cy.)
  count = 4;
  offset = 24;

  // Each Transform is an affine map x' = a*x + c*y + e, y' = b*x + d*y + f,
  // plus aMul (this copy's opacity). Include an identity copy to draw the
  // original stroke - here k = 0 is identity.
  transforms(_ctx: ToolContext): Transform[] {
    const out: Transform[] = [];
    for (let k = 0; k < this.count; k++)
      out.push({ a: 1, b: 0, c: 0, d: 1, e: k * this.offset, f: 0, aMul: 1 - k / this.count });
    return out;
  }

  settings(): SymSetting[] {
    return [
      { kind: "slider", key: "count", label: "Copies", min: 2, max: 10, value: this.count, onChange: (v) => (this.count = v) },
      { kind: "slider", key: "offset", label: "Offset", min: 4, max: 120, value: this.offset, onChange: (v) => (this.offset = v) },
    ];
  }
}

export const create = () => new EchoTool();

Register it

Export the glyph + a create() from the file, then add one row to src/symmetry/symmetry.json. The panel picker and the navbar Symmetry combo are generated from there, in file order - nothing else to touch.

// src/symmetry/symmetry.json - add one row (order = mode-picker order)
{ "name": "echo", "file": "echo.ts", "label": "Echo", "info": "Fading offset copies." }

The SymmetryTool surface

transforms(ctx): Transform[]
The only required method. Frozen once at pointer-down for the whole stroke. ctx carries the stroke anchor (startX/startY), the canvas size, the resolved centre (cx/cy) and the guide style.
settings(): SymSetting[]
Declarative controls - slider, toggle or segment. The framework renders each row and persists it under app.symmetry.<name>.<key>. Set disabled to grey one out, or persist:false for a derived control (Mirror's V/H buttons drive the Angle slider).
drawGuides(r, ctx)
Optional on-canvas guide geometry. The framework clears the overlay first and, for centred tools, draws the centre crosshair after.
usesCentre
Set true to get the shared movable Centre sliders + Recentre button, with the pivot resolved into ctx.cx/cy. Tile leaves it false and pivots on the stroke start.
mirrorsPoints(): boolean
Whether deposited points are mirrored into the searchable cloud (default true). Return false when a mode floods so many copies it would evict the cloud - Tile's "Fill canvas" does.

What you get for free

Conventions: work in canvas pixels (the renderer handles dpr); keep transforms() bounded - it runs on every pointer move and each copy redraws the stroke and deposits points (cap big counts, as Spiral does). For the user-facing guide to each mode, see Symmetry.