Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

The First Dab Where the Pen Lands

~17 min · libmypaint, painter, dabs, verification, swift, c

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

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 inputAlpha at the landing point (of 32,768)
force 0.812,681
force 0.33,997
force 0149
nothing after the landing0

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():

  1. Drawing Assist snaps the point onto the chosen guide line, holding it at the start until ten screen points of travel pick a direction.
  2. Stabilization and motion filtering move the position, before any mapping.
  3. PencilMapping.sample turns it into libmypaint's arguments (lesson two).
  4. Taper sets the base radius and scales the pressure by distance along the stroke.
  5. setAzimuth tells Ariandel's surface where the Pencil leans, for tips that follow tilt.
  6. brush.stroke, then layFirstDab on 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.

A comment is a claim about a call, so call it. "Lays exactly one there" was true in effect, wrong in place, and true only inside a timing window. Ten lines of C against the real library turned a sentence into a measurement, and the measurement found the one question that still needs a Pencil.

Code

Painter.swift, verbatim: layFirstDab·swift
    /// A dab where the pen lands, as Photoshop and Procreate lay one: libmypaint
    /// lays its first only after a spacing's travel, so a tap, or a short
    /// stroke of a stamp spaced wide, laid nothing. Its first input only sets
    /// where the brush is; one 240th of a second more at a dab per 240th lays
    /// exactly one there, and the brush's own rate comes back.
    private static func layFirstDab(_ brush: Brush, at sample: StrokeSample, on surface: BrushSurface) {
        let rate = BrushAttribute.setting("dabs_per_second")
        let own = brush.baseValue(rate)
        var again = sample
        again.deltaTime = 1.0 / 240
        brush.setBaseValue(rate, 240 * 1.001)
        brush.stroke(to: again, on: surface)
        brush.setBaseValue(rate, own)
    }
Painter.continueStroke, verbatim (trimmed to the per-sample loop)·swift
        for raw in inputs {
            var input = raw
            if !current.replaying {
                input = assisted(raw, stroke: &current, viewZoom: viewZoom)
                if current.smoothing != nil {
                    input = current.smoothing!.filter(input)
                }
            }
            var sample = mapping.sample(for: input, previousTimestamp: current.lastTimestamp,
                                        viewZoom: viewZoom, viewRotationDegrees: viewRotationDegrees)
            if current.taper != nil {
                let distance = current.taper!.advance(to: SIMD2(input.x, input.y))
                brush.setBaseValue(.radiusLogarithmic, current.taper!.radius(at: distance))
                sample.pressure *= current.taper!.pressure(at: distance)
            }
            surface.setAzimuth(input.azimuth)
            brush.stroke(to: sample, on: surface)
            if current.samples == 0 { Self.layFirstDab(brush, at: sample, on: surface) }
            if let dual = current.dual {
                dual.brush.setBaseValue(.radiusLogarithmic, brush.baseValue(.radiusLogarithmic) + dual.radiusOffset)
                dual.brush.stroke(to: sample, on: dual.secondarySurface)
                if current.samples == 0 { Self.layFirstDab(dual.brush, at: sample, on: dual.secondarySurface) }
            }
            current.lastTimestamp = input.timestamp
            current.samples += 1
            if !current.replaying {
                current.inputs.append(raw)
                current.fed.append(input)
            }
        }
        surface.end()
A harness I wrote and compiled against Ariandel's vendored libmypaint (unmodified upstream master): count the dabs of a still tap without the layFirstDab move, then with it, sweeping how long after the landing the next input comes·c
// Count the dabs libmypaint lays for a still tap, without Ariandel's layFirstDab
// move and then with it, sweeping how long after the landing the next input comes.
// Build against libmypaint master (the 2.0 API, as Ariandel vendors it).
#include <stdio.h>
#include "mypaint-brush.h"
#include "mypaint-surface.h"

static int dabs;
static int count_dab(MyPaintSurface *s, float x, float y, float radius,
                     float r, float g, float b, float opaque, float hardness,
                     float softness, float eraser, float ratio, float angle,
                     float lock_alpha, float colorize, float posterize,
                     float posterize_num, float paint) {
    dabs++;
    return 1;
}
static void no_color(MyPaintSurface *s, float x, float y, float radius,
                     float *r, float *g, float *b, float *a, float paint) {
    *r = *g = *b = *a = 0;
}

static MyPaintSurface surface;
static MyPaintBrush *brush;
static float own;

// One tap at a single point: the landing, the move (if asked), the next input
// `next` seconds later, then fifty more samples 1/240 s apart. Prints the dab counts.
static void tap(int lay_first_dab, double next) {
    mypaint_brush_reset(brush);
    mypaint_brush_new_stroke(brush);
    dabs = 0;
    // The first input of a stroke, reported 10 s after "the last one".
    mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, 10.0, 1, 0, 0, 0);
    int after_landing = dabs;
    if (lay_first_dab) {
        mypaint_brush_set_base_value(brush, MYPAINT_BRUSH_SETTING_DABS_PER_SECOND, 240 * 1.001f);
        mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, 1.0 / 240, 1, 0, 0, 0);
        mypaint_brush_set_base_value(brush, MYPAINT_BRUSH_SETTING_DABS_PER_SECOND, own);
    }
    int after_move = dabs;
    mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, next, 1, 0, 0, 0);
    int at_next = dabs - after_move;
    for (int i = 0; i < 50; i++)
        mypaint_brush_stroke_to(brush, &surface, 100, 100, 0.8f, 0, 0, 1.0 / 240, 1, 0, 0, 0);
    printf("%-11s next input %-10.6g s later | landing %d, move %d, next input %d, total %d\n",
           lay_first_dab ? "layFirstDab" : "plain", next,
           after_landing, after_move - after_landing, at_next, dabs);
}

int main(void) {
    surface.draw_dab = count_dab;
    surface.get_color = no_color;
    surface.refcount = 1;
    brush = mypaint_brush_new();
    mypaint_brush_from_defaults(brush);
    mypaint_brush_set_base_value(brush, MYPAINT_BRUSH_SETTING_RADIUS_LOGARITHMIC, 3.7f);
    own = mypaint_brush_get_base_value(brush, MYPAINT_BRUSH_SETTING_DABS_PER_SECOND);
    printf("the brush's own dabs per second: %g\n", own);

    tap(0, 1.0 / 240);
    double nexts[] = {0.001, 0.004162, 0.004163, 1.0 / 240, 0.08, 1.0, 5.0, 5.001, 6.0};
    for (int k = 0; k < (int)(sizeof nexts / sizeof nexts[0]); k++)
        tap(1, nexts[k]);
    mypaint_brush_unref(brush);
    return 0;
}

// Prints:
// the brush's own dabs per second: 0
// plain       next input 0.00416667 s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 0.001      s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 0.004162   s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 0.004163   s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 0.00416667 s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 0.08       s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 1          s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 5          s later | landing 0, move 0, next input 1, total 1
// layFirstDab next input 5.001      s later | landing 0, move 0, next input 0, total 0
// layFirstDab next input 6          s later | landing 0, move 0, next input 0, total 0

External links

Exercise

Find a comment in your own code that makes a precise claim about what a call into a library does ("this returns exactly one", "this flushes immediately", "this is idempotent"). Write the smallest program that calls the library the same way and observes the claimed effect directly, with a counter, a log or a state read-back. Record whether the claim holds, holds somewhere else, or holds only under a condition the comment does not mention.
Hint
Replace the expensive side of the call with a counter, the way the harness above replaces a surface with a function that only counts dabs. Then vary one thing at a time: the call's own arguments first, then what comes after it. A claim that is true only because of the next call is the most common shape of this kind of drift.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.