Book › Developers › Writing a brush

Writing a brush

A brush is one class (with an icon and a create()) plus a one-line row in brushes.json.

What a brush is

A brush extends BrushBase and paints continuously as the pointer moves; it can optionally connect (weave the web). Examples: Round, Marker, Squares. (Symmetry is a separate global tool that repeats any brush, so it isn't a brush type - its modes are their own plugins; see Writing a symmetry tool.)

A minimal stroke brush

Override onStroke(x, y, current) and draw with this.renderer. The base class deposits points into the memory map for you; to weave the connecting web too, attach a Connection - see Opting into connecting below.

// src/brushes/spray.ts
import { BrushBase, type BrushSetting } from "../base";
import type { Pixel } from "../neighbor-finder";

export class SprayBrush extends BrushBase {
  private spread = 8;

  name() {
    return "Spray";
  }

  // Called once per pointer sample. `current` is the deposited Pixel.
  protected onStroke(x: number, y: number, _current: Pixel): void {
    for (let i = 0; i < 6; i++) {
      const a = this.random() * Math.PI * 2;       // seeded RNG → reproducible
      const r = this.random() * this.spread;
      this.renderer.fillCircle(x + Math.cos(a) * r, y + Math.sin(a) * r, 1);
    }
  }

  getSettings(): BrushSetting[] {
    return this.persistSettings([
      {
        kind: "number",
        key: "spread",
        label: "Spread",
        min: 1, max: 40, step: 1,
        value: this.spread,
        onChange: (v) => { this.spread = v; },
      },
    ]);
  }
}

Opting into connecting

To weave the connecting web, attach a Connection - the art-style engine (Classic, Web, Fur…). Two lines:

// in the constructor - attach a default style
this.initConnection("classic");

// in getSettings() - surface its dials
...(this.connection?.sliders() ?? [])

The Web-tab style picker then swaps the style via applyArtStylePreset(), and the rest is handled: the web, fanning each connection into Weight×1px hairs, and routing. Round is the built-in example; a brush that never calls initConnection simply doesn't connect (Marker, the shape brushes). Connection styles live in src/brushes/connections/ - one ConnectionBase subclass per style, listed in connections.json.

Settings & persistence

getSettings() returns BrushSetting[] - one of number, boolean, color or select. The settings panel renders them automatically. Always wrap your array in this.persistSettings([...]): it makes each control save to LocalStorage under brush.<name>.<key> and restore on reload via restore().

Useful hooks on BrushBase

onStroke(x, y, current)
Per-sample drawing. The default stroke() wraps it.
strokeStart(x, y) / strokeEnd()
Begin/finish a gesture (e.g. reset last-point state).
initConnection(name)
Attach a connecting style in the constructor (Round does). No call → the brush never connects.
onSelect()
Run when the brush becomes active - e.g. apply an art-style or routing preset (applyArtStylePreset / applyRoutingPreset).
getSelectOpacity(): number | undefined
Push a value to the global opacity control on select (the Opacity slider atop the brush settings panel).
strokeDashValue(): DashStyle
Report the stroke's dash (used by presets/help).

What you get for free

3. Register the brush

The registry is data-driven. Export the brush's icon and a create() from its own file, then add one row to src/brushes/brushes.json. The brush map, toolbar menu, keyboard shortcut and pixel-log brush_type validation are all generated from there - nothing else to touch.

// src/brushes/spray.ts - export the glyph + a factory
import type { BrushContext } from "./registry";

export const icon = "…svg markup or a single char…";

export function create(c: BrushContext): SprayBrush {
  return new SprayBrush(c.host, undefined, c.store);
}
// src/brushes/brushes.json - add one row (order = toolbar order)
{ "name": "Spray", "file": "spray.ts", "shortcut": "0", "menuGroup": "Other" }

Every brush is built from a shared BrushContext (host - the PaintHost it paints through - plus store and getInvisibleOverlay), so create() just picks what it needs. name is the display name, storage key and pixel-log brush_type (validated against the registry, so there's no separate list to keep in sync). Add "menuGroup" to nest it under a toolbar sub-group, or "connections": true to flag a connecting brush. The same JSON-index pattern drives the connection styles via src/brushes/connections/connections.json.

Conventions: draw in CSS pixels (the renderer handles dpr); prefer this.random() over Math.random() for reproducibility; keep onStroke cheap - it runs once per coalesced pointer sample.