Repository navigation
Design brush tips
Reference for the brush-tip work. It covers the tip asset: where tips come
from, how they are made, how they are stored, and how one gets chosen at stroke
time. It does not cover the medium simulation — that is
DESIGN-fluid-media.md, and the two meet in exactly one place, noted at the
end.
A tip is an asset in a library. It is generated once and then only looked up. Nothing about a tip is computed while a stroke is being drawn.
That is already how the engine treats imported tips — BrushTipRegistry
decodes a PNG once, holds the SKBitmap and an SKImage beside it, and the
comment there says why: a stroke stamps hundreds of dabs, and wrapping the
bitmap per dab would allocate per dab and throw away the mipmap chain Skia
builds on downscale. The generator work extends that rule rather than
introducing it: procedural tips are baked to the same raster form as scanned
ones, and after that the engine cannot tell them apart.
Two consequences worth stating before anything else, because they decide the data model:
- The library stores the raster, not the recipe. A tip generated from "soft circle, hardness 0.4" is stored as pixels. Storing the recipe and re-deriving at load would mean that improving the falloff curve silently repaints every drawing that ever used it — invariant 1, from the other direction. The recipe is kept alongside as provenance so the artist can reopen and tweak, but what renders is the baked raster, and tweaking produces a new tip rather than mutating the old one.
-
Nothing here reaches the stroke record except an id.
BrushSettings.TipIdalready exists and is already the whole coupling. Everything below is about what that id resolves to.
The spec this is drawn from describes a whole engine. Most of the runtime half is built. This table is here so the tip work does not re-implement it.
| Spec asks for | Already in the code |
|---|---|
| Grayscale tip, high value = opacity |
BrushTipRegistry — importers bake grayscale into the alpha channel |
| Bake once, cache, never parse mid-stroke |
BrushTipRegistry holds decoded SKBitmap + SKImage per id |
| Bilinear filtering, never nearest-neighbour |
BrushTipSamplingTests — AnEnlargedTipIsSmoothedRatherThanBlocky, AMinifiedTipIsAveragedNotPointSampled. This was B84 and it is closed |
| Mipmap chain for small radii | Skia builds it on the cached SKImage; the registry exists partly to stop it being discarded |
| Rotation, and rotation to stroke direction |
TipRotationDeg, RotationJitter, AngleFollowsDirection
|
| Dab positions along the path, spacing, per-dab alpha × pressure × flow |
BrushEngine.StampStroke, DabPositions, GeometryOps.Densify
|
| Dual-texture engine — tip × canvas grain |
TextureSurface (PaperKind) × TextureScale × TextureDepth, via PaperField
|
| Dense spacing for oil ribbons |
Spacing; the Oil preset already ships 0.06 |
| Loading tips from art-tool formats |
.abr, .gbr, .gih, .kpp readers in Lightbox.Import
|
| Tips travel with the document |
Doc.BrushTips, resolved by TipId, inlined by ProjectIo.Flatten
|
Three places where the spec would be a regression if followed literally:
-
Do not hand-roll bilinear sampling or a mipmap chain. Skia does both, on
the cached image, and
BrushTipSamplingTestspins the behaviour. A hand loop would be slower and would reopen a closed bug. -
Do not add a second wet edge. The spec's Part 4.3 — accumulate the
borders where consecutive stamps overlap — is a stand-in for what
FluidLattice's capillary term now does properly (B24).BrushEnginealready guards against running the cheap version alongside the simulation, and adding a third would triple the rim. -
Do not build screentones with
x % spacing == 0. That is a one-pixel hard line: it aliases at every scale, and a mark whose grid phase shifts between frames boils at 12 fps, which is the failure mode CLAUDE.md names explicitly. See Hatching and screentones below for what replaces it.
One place where the roadmap currently over-claims, found while writing this:
"Brush rotation and tilt support" is ticked, and tilt does not exist.
StrokePoint is (X, Y, Pressure). Rotation is real; tilt is not read from
any device, not stored, and not used. That item is split below.
Five things, roughly in dependency order.
- A tip library. Tips exist today only inside a document, and only because an importer put them there. There is no collection, no browser, no way to make one, and nothing shared between documents.
- A procedural generator. No code produces a tip matrix from a formula. Round dabs are drawn by Skia as circles, which is fine and fast, but it means there is no path from "I want a ring, or a chisel, or a hatch" to a tip.
- An image-to-tip pipeline. The importers read brush formats. There is no path from "here is a 600 DPI scan of an ink stamp" to a usable tip — levels, squaring, centring, inversion and edge masking are all currently the artist's problem in another application.
-
Tilt, and velocity. Neither is in the stroke record. Both are
prerequisites for the multi-capture work and neither is a small change,
because
StrokePointis part of the serialized document. - Multi-capture tip sets. A tip that is several captures indexed by tilt and speed, blended at runtime.
BrushTip
Id "tip_…", globally unique, already the registry's key
Name what the artist called it
Png the baked raster — square, power-of-two, shape in alpha
Pivot (x, y) in normalised tip space, default (0.5, 0.5)
Origin Procedural(recipe) | Scanned(source, adjustments) | Imported(format)
Pivot is the one addition that is not obvious and it earns its place twice.
It is where the pen tip touches, which is not the centre of the mark for
anything captured at an angle — and it is what makes blending between two
captures align instead of ghosting. The spec makes that a scanning discipline
("align them by their pivot point"); making it a field makes it data, which
means it can be nudged after the fact and cannot be silently got wrong.
Origin is provenance, not a render input. Nothing reads it while drawing.
Where the library lives follows the rule already settled for palettes and brush presets, and for the same reason: a tip is part of how a project looks.
-
Project tips in
<project>.lbproj/tips/, shared by every document under it, resolved through the project the way palettes are. - User tips in the app's own store, available with no project open.
-
Document tips —
Doc.BrushTips, which already exists — stay as the flattening target.ProjectIo.Flatteninlines the tips a document actually references, so an exported file still renders standalone. That is invariant 1 at the boundary, and it is machinery that already works; the tip library just becomes another thing it inlines.
A tips panel, absent until it has something to show, on the pattern the symbol panel already set.
Two places, and the split is between making and using. The workshop —
Edit ▸ Brush tips…, its own window — is where a tip is generated, scanned,
renamed or promoted to the project, none of which anybody does mid-stroke. The
picker is in the brush options, and it is only a choice.
The picker is a flyout of thumbnails in two columns, not a dropdown of names. A tip is a shape, and a name is a poor handle on one: nobody knows what a "Cut nib" looks like until they have seen it, and once they have they recognise the picture and never read the name again. The two columns are the narrowest arrangement in which a 60px tile is still legible at the width the tool-options flyout already is.
Three details that are not obvious:
- Round comes first and is not a tip. It is the engine's own dab, drawn rather than baked from the catalogue, because it must not look like a library entry that could be deleted.
- Choosing copies the raster into the document. Invariant 1 for tips: from that moment the drawing renders whether or not the library still has it. This is why deleting from the library is safe, and why going back to Round does not remove the raster — strokes already painted with it still reference it.
- A tip that has left the library reads as "Custom", not "Round". The mark still renders from the document's own copy, and saying Round would be a lie about what the brush is doing.
A square D × D matrix, D = 2 × Radius, centre at (Radius, Radius), alpha
from the Euclidean distance d to the centre. Baked to PNG and handed to the
library; never evaluated during a stroke.
Every formula below is anti-aliased at the boundary. The spec's hard
if d <= Radius produces a stair-stepped circle, and at animation frame rates
the steps crawl. The rule for the whole generator: wherever a formula has a
threshold, the output is the coverage of that threshold over the pixel — one
smoothstep across a one-pixel band — not a binary test.
-
Hard circle —
1inside,0outside, with a one-pixel feathered boundary. "Hard" describes the falloff, not the sampling. -
Soft circle — full inside
Radius × Hardness, then smoothly to zero atRadius. Worth noting the engine already has aHardnesswith an established curve for its round dab; the generator must produce a tip that matches it at the same hardness, or the same slider will mean two different things depending on whether a tip is selected. -
Ring — full between
InnerRadiusandOuterRadius, feathered on both edges. This is the one the spec gets right as written. -
Chisel / flat — not in the spec and it should be: it is the shape that
makes
AngleFollowsDirectionworth having, and it is the cheapest useful answer toBristleDrag(see the last section).
The test for adding a shape is whether the ones already there can reach it. A "soft round 2" cannot pass that; these five can. Each is a standard curve rather than an invented one, because a named curve comes with a control whose behaviour is already understood.
-
Bristle — a raised-cosine comb,
½(1 + cos Nθ), around the azimuth. Two ways to build it that both stamp a starburst instead of a brush, and both were built here before the right one: multiplying by the comb keeps a ridge at each hair and discards everything between them, and inverting that still leaves grooves a quarter of the hair spacing wide. What works is(1 − comb)^kat a high exponent — a narrow spike at each parting — subtracted from a solid disc at a depth below one, so a scratch thins the paint rather than removing it. It fades to nothing over the inner third, because a comb that reaches the centre is a star. -
Superellipse — Lamé's
|x/a|^n + |y/b|^n = 1. One exponent walks from a pointed almond through an ellipse to a squarish marker nib, which is three shapes for one control and one slider. The implicit value is converted back to a distance in pixels before feathering, the same correction the chisel makes, or the soft edge is a different width on each axis. -
Polygon — the polar form
cos(π/N) / cos((θ mod 2π/N) − π/N). The only procedural shape with corners. Rounding lerps toward the inscribed circle, the one the flats already sit on: rounding a corner must take material away, and the temptingflat → 1grows the tip as the slider moves, which an artist reads as a bug in the size slider. -
Spatter — Worley (cellular) noise. Value noise thresholds into ragged
blobs with no characteristic size; cellular noise measures distance to the
nearest of a set of feature points, so grains have a size and that size is the
cell spacing — a control someone can reason about. Feature points come from
Hash01on the cell index, never an RNG: a tip that baked differently on two machines would make the same document render differently on each, which is invariant 2 one level up. -
Halo — a Gaussian rim band over a low plateau: the profile a drying puddle
leaves, pale in the middle and dense at the contact line.
FluidLatticeproduces this properly from the flow; this is the stamped counterpart for a brush that is not paying for a simulation, which is the same "every simulated medium ships a fast counterpart" rule the preset catalogue follows.
TipCatalogue — soft round, hard round, paintbrush, and one of each of the five
above. Recipes plus frozen ids, not shipped PNGs. Eight files would put a
megabyte of pixels in the repository to hold what eight lines of arithmetic
already contain, and would make "the same tip" mean two things depending on
whether the file was found.
The ids are the part that cannot change. A document that painted with a built-in
copied the raster into Doc.BrushTips under that id, so the id is in that file's
record forever: renaming one is a compatibility break, and re-tuning the pixels
behind one silently repaints old drawings — invariant 1 read from the wrong end.
Adjusting a built-in means adding a new one, and the workshop's Edit a copy
is how an artist does that without being able to get it wrong.
Two different features that the spec runs together, and separating them is the whole design note here.
-
Hatching as a tip — the grid is in tip-local space, so it rotates with
the dab and follows the stroke. That is what you want for a cross-hatching
brush. Built as: signed distance to the nearest grid line,
smoothsteped over the line width. Line width and spacing are both in tip pixels, so scaling the brush scales the hatch, which is correct for a mark. - Screentone as a fill — the grid must be locked to the document, not to the dab, or the dots swim as the stroke turns and the whole point of a screentone is lost. That is not a brush tip at all; it is a pattern fill, and it belongs with the fill tool. Recording it here so nobody builds half of it as a tip and then discovers the other half.
The capture discipline in the source spec is good and this document does not restate it: high-contrast black media on smooth white stock, varied pressure and angle, 600 DPI flatbed rather than a camera, scan the whole series in one pass under one setting.
What matters here is which half of the isolation workflow the app should own, because the spec assumes all of it happens in a photo editor.
In the app, because these are mechanical, checkable, and the place they go wrong is silent:
- Luminance, then levels. Black point and white point, previewed, with the histogram. Same adjustment applied across a whole selected batch in one action — the spec's most important rule for lerping is that every capture in a series gets identical levels, and the way to guarantee that is to make applying them separately the harder path.
- Invert. Ink-on-paper to shape-on-nothing.
- Square, centre, resample to the chosen power of two.
- Edge masking, enforced rather than requested. The spec says "ensure the edges fade to black". The generator should guarantee it — multiply by a short border ramp — and it should refuse, loudly, when significant alpha reached the boundary before the ramp, because that means the crop cut through the mark and the fix is to re-crop, not to fade. A tip that stamps visible box edges down a stroke is the single most common way a hand-made brush is ruined, and it is trivially detectable.
- Pivot. Default to the centroid of the alpha mass, which is right for a symmetric stamp; let the artist drag it, which is what an angled capture needs.
Not in the app: dust removal, despeckling, and anything else that is general photo retouching. Those exist elsewhere and doing them badly here is worse than not doing them.
StrokePoint is (X, Y, Pressure). It is a serialized part of the document.
Adding to it is the largest hidden cost in this whole area, and the
multi-capture feature is impossible without it.
-
Both must be optional, not defaulted. A mouse has no tilt, and
0is a legal tilt value meaning "perpendicular" — so absent has to be distinguishable from zero, or every mouse stroke claims to have been drawn with the pen held upright and any tilt-driven tip picks the wrong capture. - Old files must load unchanged, and a document that never saw a tilt-aware pen must serialize with no tilt keys at all. Same rule as the camera: optional means absent, not present-and-zero.
-
Velocity needs time, which is also not stored. Note for the fluid-media
document: it claims "the stroke already carries the timestamps". It does not.
Either a per-point timestamp joins the record, or velocity is derived from
point spacing — which is a resampling artifact, not a speed, and after
Densifyand the smoothing filters it is not even that. Timestamps are the honest answer and they are the same widening problem as tilt, so the two should land together. - Whatever is stored must be what was replayed. Deriving speed at render time from wall-clock would break invariant 2 outright.
A tip becomes a small grid of captures indexed by tilt and by speed, blended at stamp time:
Output = Lerp(A.Alpha, B.Alpha, t)
The idea is right and the naive implementation is not affordable. Three things the source spec does not address:
- Blend at the size being drawn, not at the source size. Two 1024² captures lerped per dab is a million operations per dab, hundreds of times a stroke. Blend at the mipmap level the dab will actually sample.
-
Quantise
t, and cache the result. Blend at, say, sixteen steps and key the blended tip by(setId, level, step). A stroke drawn at a steady tilt then blends once and reuses it, and the quantisation removes the risk of the mark shimmering as tilt wobbles by a degree — which is the same anti-boil argument as everywhere else. - Alignment is the pivot field, not a scanning rule. Blend with each capture offset so its pivot lands on the dab position. Ghosting is what happens when this is left to how carefully someone cropped.
Cost. A tip set is more memory and real per-dab work, so BrushCostOf
should treat a brush carrying one as Expressive — the badge exists so that
trade is made knowingly, and this is exactly the kind of thing it is for. That
is a decision to take when the feature lands, not before.
One place. B23's remaining half is BristleDrag and Pickup, and
DESIGN-fluid-media.md already concluded that a directional tip with
AngleFollowsDirection delivers BristleDrag's appearance far more cheaply
than the advection loop. A chisel tip from the generator, or a scanned
dry-brush skim, is therefore the practical route to half of that bug — and a
scanned bristle is structured where Hash01 is not, which is the honest
answer to "it looks like a texture created from noise".
Pickup is not reachable this way. A tip is a stamp that knows nothing about
the canvas, and pickup is a coupling to what is already there. It stays with
the advection loop.
-
The library and the tip record. Everything else writes into it, and
getting
Pivotand the project/user/document scoping right first is cheaper than retrofitting them under two producers. - The procedural generator. Self-contained, testable against exact formulas, and it makes the library non-empty on day one.
- The scan pipeline. Same target, more UI, and the edge-mask check is the part that earns its keep.
- Tilt and velocity in the record. Deliberately after the first three, because it is a serialization change and should not be entangled with a feature that is still moving.
- Multi-capture sets. Last, and only once 4 is real.
Whether procedural tips are re-generatable in place (edit the recipe, replace the raster) or always fork to a new tip — the argument above favours forking, but it has a real cost in library clutter. Nor the on-disk format for a tip set versus a single tip; nor whether the tips panel is its own dock or a mode of the brush picker; nor the tilt curve, which will want the same editor the pressure curves already have.
- Getting started
- Documents and projects
- Tools and strokes
- Brushes
- Colour
- Layers, selections and guides
- The timeline
- Onion skin, references and the camera
- Symbols
- Saving and recovery
- Exporting to a game engine
- AI assistance
- Keyboard, performance and what is planned
- Bones and rigging
- Effects — fire, smoke and water
- A simplified 3D space to draw in: planes, parallax first, orbit second
- Brush performance beyond 4k: what the GPU can and cannot buy us
- The add-on boundary
- Making the AI trustworthy
- What an AI request actually costs
- Bones and spline deformers for 2D — design
- Brush tips — a generated library, not a live calculation
- Cloud and SaaS: what the architecture already fits, and what it does not
- Colour mixing — paint that picks up what it is laid on
- DESIGN: Effects
- DESIGN: Fluid effects — fire, smoke and water as drawn line and fill
- Fluid and viscous media — notes for the next brush-engine pass
- Compositing on the GPU
- Decomposing the two big files: which pieces come out, and with which tool
- One memory limit for pictures
- .NET 10: what is safe, what is unverified, and what it narrows
- One picture cache: the paused canvas on tiles
- The paint path: one mark, made once, under the pen
- Pen dynamics: tilt and speed
- A pencil is graphite on the paper's tooth
- Performance: the ratchet and the map
- Playback's first loop
- Pressure curves — a shape, not an exponent
- Importing Procreate brushes
- A project as a game's asset set
- One hierarchy: folders scope everything
- Puppet pins — design and plan
- The raster checkpoint: making a painting cheap to reopen
- Scoped resources — palettes, references and everything else shared
- Sensitive topics, and the three-track flow
- Design: pose-assisted frame-by-frame animation (rigid bones + AI gap filling)
- Stroke groups: why a merge should never turn strokes into pixels
- The project window: what you do between drawings
- Reading the subject before drawing on it
- The ceiling of a swept soft mark (B349)
- Q171 — a symbol owns a layer stack
- Pillar 3 — reusable assets, as live symbols
- What a template is
- Text, and where fonts sit
- The tiled compositor, and getting it off the CPU
- Design tokens
- Transform tool design (M14)
- Why a brush stamps twice, and when it does not
- Migrating to the designed UI
- Undo stores only what changed
- Vector tooling: making the lines you already drew editable
- The smudge/blur family, and aligning brush settings with Photoshop
Generated from the repository. Edit the files under docs/, not these pages.