Contents

Brushes

peinten ports the brush engine of libmypaint's master branch: its settings, inputs and mapping curves, the dab state machine, smudge and the 2.0 inputs (barrel rotation, tilt declination, attack angle and view zoom). .myb files in MyPaint's current JSON format (version 3) load as they are; settings and inputs this port does not know are listed in ignored and skipped, as libmypaint does. Other versions are rejected.

Resources

The active brush is part of the runtime's shared resources, together with the color and Krita-style overrides.

await runtime.setResources({
  color: [0.9, 0.2, 0.1], // straight RGB in 0..1
  eraser: true,           // any brush erases instead of painting
  size: 40,               // diameter in pixels, or null for the brush's own size
  opacity: 0.5,           // multiplier, or null
});

Brush files and presets

import { parseMyb, presets, serializeMyb } from 'peinten';
 
const { brush, ignored } = parseMyb(await file.text(), 'My brush'); // `ignored` lists unknown settings
await runtime.setResources({ brush });
 
await runtime.setResources({ brush: presets.find((preset) => preset.name === 'Pencil')! });
const text = serializeMyb(brush);

The bundled presets come from mypaint-brushes. Settings metadata (BRUSH_SETTING_INFO, BRUSH_INPUT_INFO) and helpers such as baseValueOf and withBaseValues let you build brush editors.

Previewing a brush

A preview is a small document plus a view without input. Its history and GPU caches stay separate from your main canvas. previewStrokeSamples produces an S-shaped stroke whose pressure rises and falls, and resolveBrush applies the same overrides the brush tool uses, so the preview matches what the user paints.

import { ops, previewStrokeSamples, resolveBrush } from 'peinten';
 
const size = { width: 512, height: 96 };
const preview = await runtime.createDocument({ ...size, history: false });
await runtime.createView({ canvas, document: preview, input: false, background: 'transparent' });
 
// Redraw whenever runtime.getSnapshot().resources changes.
const nodeId = preview.getSnapshot().rootIds[0]!;
await preview.dispatch([
  ops.clear({ nodeId }),
  ops.stroke({ target: { nodeId }, brush: resolveBrush(resources), samples: previewStrokeSamples(size), seed: 1 }),
]);

The fixed seed keeps the preview stable for a given brush.

Pigment mixing

Colors mix in three places: picking up color under the brush, smudging, and laying a dab on the canvas. Each blends an additive result with a pigment result according to the brush's paint_mode. The pigment part is pluggable:

interface ColorMixer {
  readonly id: string;
  readonly latentSize: number;
  toLatent(r: number, g: number, b: number, out: Float32Array, offset: number): void;
  fromLatent(latent: Float32Array, offset: number, out: Float32Array): void;
}

The default libMyPaintPigmentMixer mixes ten spectral bands in log space, like libmypaint 2.0. Any mixer that interpolates linearly in a latent space fits, Mixbox included. Mixbox's license does not permit commercial use, so it is not bundled; write an adapter if your license allows it. Pass the mixer as colorMixer; every collaborator must use the same one, because mixing changes the pixels every peer recomputes.

Strokes and determinism

A stroke operation stores the brush, the target, a seed and its input samples (position, pressure, tilt, time, view zoom and rotation, barrel rotation) as a Float32Array. Live painting uses the same quantized samples, so replaying the operation produces identical tiles. Brush state resets for each stroke so that a stroke can be recomputed on its own; unlike in MyPaint, smudge color therefore does not carry over from one stroke to the next.