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
this.host- the full PaintHost (renderer + memory maps + routing).this.renderer/this.finder- role views of the host: the IRenderer to draw with, the memory map to deposit/search points.this.random()- a seeded PRNG, so a redrawn stroke is reproducible.this.store- the key/value store (e.g. read the secondary color).- Connecting:
this.initConnection(name)+ spreadthis.connection?.sliders()and the web is handled.
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.
this.random() over Math.random() for
reproducibility; keep onStroke cheap - it runs once per
coalesced pointer sample.