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

The 180-Degree Trap

~13 min · libmypaint, geometry, symmetry, debugging, c

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

Title Brushes, Upside Down

Commit 139c64f (2026-10-03, 00:16 KST) gave Ariandel shaped tips: Procreate's Shape Source, a picture stamped where libmypaint asks for a round dab, through Ariandel's own draw_dab. Before the half hour was out, imported Procreate brushes set beside Procreate's own thumbnails showed the stamped title brushes upside down and mirrored. The coop learning that records it (#756, scope libmypaint) puts the symptom exactly: a tip whose picture sits in its top-left corner was stamped into the bottom-right corner.

"Upside down and mirrored" is worth slowing down on. Flip a picture vertically and horizontally and you have turned it half a turn. A single flip changes one axis; a half turn changes both. The description already contained the diagnosis.

The Wrong First Suspect

The first hypothesis was a vertical flip in decoding the picture: Core Graphics puts its origin at the bottom-left, and an image read into a buffer upside down is the oldest bug in the book. It was a reasonable suspect, and it was wrong. A round trip proved the decoded picture upright. A decode flip would also have mirrored only one axis, and the symptom had both.

Line 903

The cause was in libmypaint, and it was not a bug there. Before handing draw_dab its angle, mypaint-brush.c corrects it for the view's rotation (first code block):

ACTUAL_ELLIPTICAL_DAB_ANGLE = mod_arith(setting − viewrotation + 180, 180) − 180

That folds every angle into the range −180 up to (not including) 0. The default setting of 90° arrives as −90°. A setting of 0 and a setting of 180 arrive as the same number. The angle draw_dab receives is only known modulo half a turn.

For libmypaint's own dab that loses nothing. Its dab is an ellipse, and an ellipse turned half a turn is the same ellipse. The reduction is correct for the only shape the engine ever draws.

Ariandel's first shaped-tip code trusted that angle. It turned the tip by angle − rest_angle, with BrushShape.restAngle = 90 and a doc comment that read: "The angle libmypaint gives a dab when the brush does not turn it: the tip stands upright there." libmypaint gives −90. So an unturned brush turned its tip by −90 − 90 = −180°: exactly half a turn, exactly the symptom.

The Probe That Found It

The learning records the experiment that settled it, and it is a model of a good probe. Make a tip with only its top-left quarter white: an asymmetric shape where any rotation or flip is visible in one bit. Stamp it at (100, 100) with radius 30. Paint appeared at (120, 120), down and right of center, and none at (80, 80), up and left. One stamp, two pixels, and the half turn is beyond argument. The second code block rebuilds that probe from the formulas: libmypaint's mod_arith, the delivered angle, and the same tip-space sampling Ariandel's stamp() uses.

The Fix

Commit 963bef1 (00:43 KST) stopped asking libmypaint which way the tip points. The surface now discards the angle argument outright with (void)angle and builds the turn from sources that know the full circle (third code block):

  • The tip's own angle, BrushShape.angle: Procreate's Shape Angle, a slider in Brush Studio, set by the importer.
  • The stroke's heading, which the surface computes itself from successive dab positions with atan2f: a full-circle direction, weighted by follow.
  • The Pencil's azimuth, handed in per input through ard_surface_set_azimuth, weighted by follow_tilt.
  • A random turn up to scatter of a half turn either way, from the stroke's own seed.

libmypaint still contributes the dab's ratio, which squeezes the tip along its height; a ratio has no direction, so nothing was lost there. Symmetry reflections pass a mirror flag and a reflected turn, so a mirrored dab carries a mirrored tip.

The code fix came first. The lesson was written down three and a half hours later, at 04:17, in two places at once: a line on the house sign (c9bd18e), and coop learning #756, which is where the probe's numbers live.

An engine's convention that is harmless for its own primitive becomes a bug the moment you add a less symmetric one. libmypaint reduced the angle by the ellipse's own symmetry, which was free. A picture has no symmetry, so the reduction became information destroyed. Whenever you extend an engine with a richer primitive, list every quantity it hands you and ask: what symmetry of the old primitive was this value allowed to forget?

The Shape of the Mistake Elsewhere

This trap is not about painting. A unit quaternion q and its negation −q describe the same rotation, which is harmless until you interpolate between two of them and take the long way round. An undirected edge drops its direction, which is harmless until you need to walk the graph one way. A surface normal with an arbitrary sign is harmless for a two-sided material and wrong for a one-sided one. In each case the producer was right for its consumer, and a new consumer inherited a value that had quietly forgotten something.

Code

Where the half turn is lost (CMyPaint/mypaint-brush.c, vendored libmypaint, verbatim)·c
// correct dab angle for view rotation
STATE(self, ACTUAL_ELLIPTICAL_DAB_ANGLE) = mod_arith(SETTING(self, ELLIPTICAL_DAB_ANGLE) - viewrotation + 180.0, 180.0) - 180.0;
A minimal sketch of the probe from learning #756 (mod_arith's body verbatim from libmypaint's helpers.c; compiled and run with clang)·c
/* Why an angle known modulo 180 cannot orient an asymmetric tip.
   mod_arith is libmypaint's (helpers.c); delivered() is the line in
   mypaint-brush.c that hands draw_dab its dab angle. covered() samples a
   tip the way Ariandel's stamp() does: canvas offset turned into tip space,
   (u, v) in -1...1. The tip is the probe from learning #756: only its
   top-left quarter is white. */
#include <math.h>
#include <stdio.h>

static float
mod_arith(float a, float N)
{
    float ret = a - N * floor (a / N);
    return ret;
}

/* ACTUAL_ELLIPTICAL_DAB_ANGLE, with no view rotation. */
static float
delivered(float setting)
{
    return mod_arith(setting + 180.0f, 180.0f) - 180.0f;
}

/* Does a tip stamped at (cx, cy), radius r, turned by `angle` degrees,
   cover pixel (px, py)? */
static int
covered(float cx, float cy, float r, float angle, int px, int py)
{
    const float t = angle * (float)M_PI / 180.0f, cs = cosf(t), sn = sinf(t);
    const float dx = (float)px + 0.5f - cx, dy = (float)py + 0.5f - cy;
    const float u = (dx * cs + dy * sn) / r, v = (-dx * sn + dy * cs) / r;
    if (u <= -1.0f || u >= 1.0f || v <= -1.0f || v >= 1.0f) {
        return 0;
    }
    return u < 0.0f && v < 0.0f; /* the top-left quarter of the tip */
}

int
main(void)
{
    printf("setting -> delivered\n");
    for (int s = 0; s < 360; s += 45) {
        printf("  %3d -> %6.1f\n", s, delivered((float)s));
    }

    /* Before 963bef1 the tip turned by libmypaint's angle less its rest angle
       (the default setting, 90). After it, by its own angle (0 here). */
    const float rest = 90.0f;
    const float old_turn = delivered(rest) - rest;
    const float new_turn = 0.0f;
    printf("\nprobe: stamp at (100,100), radius 30\n");
    printf("  turned by libmypaint (%.0f):  (80,80) %s  (120,120) %s\n", old_turn,
           covered(100, 100, 30, old_turn, 80, 80) ? "paint" : "none ",
           covered(100, 100, 30, old_turn, 120, 120) ? "paint" : "none ");
    printf("  turned by its own angle (%.0f): (80,80) %s  (120,120) %s\n", new_turn,
           covered(100, 100, 30, new_turn, 80, 80) ? "paint" : "none ",
           covered(100, 100, 30, new_turn, 120, 120) ? "paint" : "none ");
    return 0;
}

/* Output:
setting -> delivered
    0 -> -180.0
   45 -> -135.0
   90 ->  -90.0
  135 ->  -45.0
  180 -> -180.0
  225 -> -135.0
  270 ->  -90.0
  315 ->  -45.0

probe: stamp at (100,100), radius 30
  turned by libmypaint (-180):  (80,80) none   (120,120) paint
  turned by its own angle (0): (80,80) paint  (120,120) none 
*/
The tip turns by its own angle (CAriandelSurface/ariandel_surface.c, shaped_draw_dab, verbatim, re-indented)·c
/* libmypaint's dab angle is only known modulo half a turn (an ellipse
   looks the same either way up), so a tip turns by its own angle instead. */
(void)angle;
float turn = self->angle + self->follow * (self->heading + 90.0f);
if (self->follow_tilt > 0.0f && self->has_azimuth) {
    turn += self->follow_tilt * (self->azimuth + 90.0f);
}
if (self->scatter > 0.0f) {
    turn += self->scatter * (random_unit(self) * 360.0f - 180.0f);
}
const int fx = self->flip_x && (next_random(self) & 1);
const int fy = self->flip_y && (next_random(self) & 1);

External links

Exercise

Find a value in your own code that an upstream library hands you already reduced: an angle wrapped to a half or quarter turn, a direction without a sign, a normalized vector that lost its length, a timestamp truncated to the minute. Write the one-line test that would catch it if a new consumer needed the information that was dropped, using the quarter-white trick: an input asymmetric enough that the loss shows up in a single observation.
Hint
Start from the library's documented range for the value. If a value that could be anywhere on a circle is documented, or observed, to live in only half of it, something was reduced. Then ask whether every consumer of that value is as symmetric as the producer assumed.

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.