A Tap That Painted Nothing
libmypaint decides how many dabs to lay between two inputs by adding three terms: the distance travelled divided by the actual radius times dabs per actual radius, the same distance against the base radius times dabs per basic radius, and the elapsed time times dabs per second. Whenever the running total crosses 1, a dab goes down. Now recall lesson two: a stroke's first input arrives ten seconds after "the last one", libmypaint resets the brush, and that branch returns before any dab is laid. The first input only says where the brush is.
A tap does not travel, and most brushes lay zero dabs per second. So the total never reaches 1, and on 2026-10-03 the commit that fixed it (8973b8a, 19:38) stated the damage plainly: "A tap therefore painted nothing with any brush." The same arithmetic hid a second symptom. A stamp brush spaced two radii apart painted nothing on any stroke shorter than its spacing. Photoshop and Procreate both stamp the moment the pen lands. Measured on Dad's own Photoshop brush sets in a release build, taps went from painting with none of a set's brushes to painting with most or all of them.
The Move: a 240th of a Second at a Dab per 240th
Painter.layFirstDab (first code block) runs right after the first input. It remembers the brush's own dabs-per-second, sets it to 240 × 1.001, feeds the same point again with deltaTime = 1/240, and puts the rate back. A 240th of a second at a little over 240 dabs a second is a little over one dab, and the extra 0.1% keeps rounding from leaving the count a hair under one. A dual brush gets the move on its secondary brush too.
Where the Dab Actually Falls
The comment above the function says libmypaint "lays exactly one there." Before repeating that in a lesson, I compiled Ariandel's vendored libmypaint and counted dabs with a surface that does nothing but count (third code block). The result: the second call lays nothing. The dab falls at the stroke's next input, and with a brush at zero dabs a second exactly one falls there, provided that input comes inside a window the comment never mentions: from about 4.16 ms to five seconds after the landing.
The reason sits in mypaint-brush.c. The count reads dabs-per-second from the brush's state, not its base value. The reset zeroed every state, and the state is recomputed from the base values only at the end of a call. So layFirstDab's call primes the rate without spending it. The next input spends it on one dab, and that dab's own state update brings back the brush's own rate. The 1.001 margin still earns its place: a Pencil's next sample comes about a 240th of a second later, which is what makes the primed count land just over one. The third code block sweeps that interval to find the window's edges. The low edge is the margin's own arithmetic. The primed count is the interval times 240.24 and has to reach one, so the edge sits at 1/240.24 of a second, 4.1625 ms, a hair under a 240th: 4.162 ms lays nothing and 4.163 ms lays the dab. The primed rate lasts one call. A next input sooner than the edge spends less than one dab, that call's state update brings the brush's own rate back anyway, and at zero dabs a second a pen held still never lays the dab at all. The high edge isn't Ariandel's. It is lesson two's five-second reset: an input more than five seconds after the last one makes libmypaint reset the brush and return before laying anything, and the reset throws the primed rate away with the rest of the state. Five seconds lays the dab; 5.001 and six lay nothing. Everywhere between the two edges, from the 4.163 ms row through the 5 s row, the count is exactly one.
It matters what that next input is, because libmypaint interpolates the dab toward it. I built the real engine (build 24, release) in a scratch package and replayed taps through Painter. A landing at force 0.8, followed by a sample a 240th later:
| Next input | Alpha at the landing point (of 32,768) |
|---|---|
| force 0.8 | 12,681 |
| force 0.3 | 3,997 |
| force 0 | 149 |
| nothing after the landing | 0 |
On the canvas, a tap always has a next sample: its lift handler feeds the end event's coalesced touches before the lift itself. How much force a real Pencil still reports in those last samples is a hardware fact no test here measures. Brush Studio's Drawing Pad does not feed the end event's coalesced touches, only the lift. But endStroke sets the lift's force to 0 and feeds it as one more sample, so a pad tap with no move event still has a next input: force 0, a tap's length after the landing. The table's 149 is that case at a 240th of a second. A real tap lasts longer, and that changes the answer, because the primed dab falls about a 240th of a second into the interval, not at its end, with the pressure interpolated between the two samples. A variant of the third code block that records each dab's opacity shows it: after a landing at 0.8, a force-0 next input a 240th later gives 0.0002, 20 ms later 0.24, and 80 ms later 0.32, against 0.35 when the next input also presses at 0.8. The engine test passes either way, because it feeds seven samples at force 0.8. None of this undoes the fix. It narrows what was proven: a tap paints in the engine when its next input comes between about 4.16 ms and five seconds after the landing, and its strength is read about a 240th of a second into the way from the landing to that input.
The Batch, in Order
continueStroke runs every sample through the same sequence (second code block), between surface.begin() and surface.end():
- Drawing Assist snaps the point onto the chosen guide line, holding it at the start until ten screen points of travel pick a direction.
- Stabilization and motion filtering move the position, before any mapping.
PencilMapping.sampleturns it into libmypaint's arguments (lesson two).- Taper sets the base radius and scales the pressure by distance along the stroke.
setAzimuthtells Ariandel's surface where the Pencil leans, for tips that follow tilt.brush.stroke, thenlayFirstDabon the stroke's first sample, then the dual secondary with its radius offset.
Steps 1 and 2 are skipped when a stroke is replayed, because a taper's redraw at the lift feeds positions that were already assisted and smoothed. After end() come the per-batch merges of Track 5.
The Dab Budget
One more limit is set before the first sample, in beginStroke. A dab costs its area, so a stroke's cost grows with its radius times its dabs per radius. Photoshop's 1% spacing is fifty dabs a radius, and in a release build one second of a 300-pixel brush at 1% took one to two seconds to draw (7241359). Painter.dabBudget is 3,000: while the stroke paints, dabs per actual radius is held to at most max(2, 3000 / radius), so 30 at 100 pixels and 10 at 300, and the brush's own value comes back when the stroke ends.