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

Reuse the Engine, Own the App

~13 min · architecture, boundaries, libmypaint, scope

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

One Sentence in the House Sign

Every family repository has a CLAUDE.md that says what can't be reconstructed from the code. Ariandel's design philosophy opens with this line:

"Brush physics is libmypaint's, vendored untouched. Everything an app should own — tiles, layers, compositing, persistence, input, interface — is ours."

That sentence draws a border. Getting the border right matters more than anything you do on either side of it, because it decides which problems you get to solve once and which ones you'll solve forever.

What the Engine Gives You

Three things, and each one is the hard kind.

  • Dab physics. Where each dab lands along a stroke, how it's rasterized, how smudge picks up color, how pigment mixes. Architecture invariant 1 names all four as libmypaint's.
  • A brush model. 65 settings, each one a base value plus curves over 18 inputs, stored as .myb presets. Ariandel loads them through libmypaint's own parser (mypaint_brush_from_string), so MyPaint's brush format and its CC0 brush library come along for free. That's also why json-c rides along: the parser needs it.
  • A tiled-surface contract. libmypaint doesn't own pixels. It asks a MyPaintTiledSurface for 64×64 tiles of premultiplied 15-bit RGBA, paints into them, and hands them back. Its header says it plainly: "The size of the surface is infinite, and consumers need just implement two vfuncs."

What You Have to Own

Look at what's left once the engine has done its part. The evaluation's table says libmypaint has no layers, "by design: the surface interface is the host's job." That's the engine telling you where your work starts.

PartWhere it lives in Ariandel
TilesAriandel's own sparse tile store behind that two-function contract; the store is also where undo captures each tile's before-state
LayersLayer.swift and friends: opacity, visibility, blend modes, groups, masks
UndoHistory.swift: tile-exact steps, bounded by bytes
FilesArtworkFile.swift: the .ariandel package, every layer's tiles in the engine's native format, LZFSE-compressed
CompositingThe Metal compositor in CanvasRenderer.swift
InputPencilMapping: the one place Pencil force, altitude, azimuth and roll become libmypaint inputs
InterfaceThe iPad app itself
CompanionNot built here at all: cwkPippa's Sidekick panel, embedded beside the canvas

The seam between the two halves is a single call. Every sample from the Pencil enters libmypaint through mypaint_brush_stroke_to, carrying position, pressure, both tilts, elapsed time, view zoom, view rotation and barrel rotation. The brush queues its dabs on the surface; the surface owns where they land. Nothing in Ariandel's Swift reaches into libmypaint's internals, and nothing in libmypaint knows a layer exists.

A Sketchbook Is a Start, Never a Reduction

There's a classic way to shrink an ambitious project without anybody noticing: ship the smallest thing that technically works and quietly let it become the plan. For a painting app, that's the single-layer sketchbook. The house sign closes that door by name: "The ambition is Procreate-class. A single-layer sketchbook is a milestone, never the product." And it records Dad's ruling from the first day: "Basic layers are not hard." Layer handling is familiar implementation work. The genuinely hard, genuinely unknown parts are brush feel, Pencil responsiveness, and making it all hold together, and those are exactly what the engine choice was protecting.

The git log shows how seriously that was meant. The repository was born at 21:51. The engine package landed at 22:09. Layers, blend modes, history and the .ariandel package landed at 22:14. The Metal compositor at 22:19. Build 1 went to TestFlight at 22:51, and it already had layers. The single-layer sketchbook never shipped, not even once.

Where the Growth Went

At build 24 the numbers tell the same story. libmypaint's 5,419 lines of C sit in a directory that exactly one commit, of 167, has ever touched: the one that put them there. Ariandel's own C bridge has been touched by 22 commits and has grown to about 2,800 lines. The engine package holds about 14,400 lines of Swift, the app about 17,500. All the growth landed on the side Ariandel owns. That's what a healthy border looks like from the inside.

The Name

Ariandel takes its name from the Painted World of Ariandel in Dark Souls III, a world a painter paints anew. It fits the boundary rather well. Dad is the painter. Pippa lives in the world, looks at it and talks about it, and never paints it for him.

Take from the engine what's hard and finished; own everything that makes it your app. If you ever find yourself shrinking the product to fit the engine, or patching the engine to fit the product, the border is in the wrong place.

Code

Trimmed from libmypaint's mypaint-tiled-surface.h at d5a88fb: the contract a host implements·c
typedef struct {
    int tx;
    int ty;
    gboolean readonly;
    guint16 *buffer;
    gpointer context; /* Only to be used by the surface implementations. */
    int thread_id;
    int mipmap_level;
} MyPaintTileRequest;

/* ... mypaint_tile_request_init and the function-pointer typedefs (trimmed) ... */

/**
  * MyPaintTiledSurface:
  *
  * Interface and convenience class for implementing a #MyPaintSurface backed by a tile store.
  *
  * The size of the surface is infinite, and consumers need just implement two vfuncs.
  */
struct MyPaintTiledSurface {
    MyPaintSurface parent;
    /* private: */
    MyPaintTileRequestStartFunction tile_request_start;
    MyPaintTileRequestEndFunction tile_request_end;
    /* ... symmetry data, operation queue, bounding boxes, tile size (trimmed) ... */
};

void
mypaint_tiled_surface_init(MyPaintTiledSurface *self,
                           MyPaintTileRequestStartFunction tile_request_start,
                           MyPaintTileRequestEndFunction tile_request_end);
Verbatim from Ariandel's Brush.swift: the one call every Pencil sample goes through·swift
    /// Feeds one input sample. Dabs are queued on the surface and rendered
    /// at the surface's `end()`.
    public func stroke(to sample: StrokeSample, on surface: BrushSurface) {
        // A grain whose depth follows the pressure reads it as the stamps are laid.
        ard_surface_set_pressure(surface.handle, sample.pressure)
        _ = mypaint_brush_stroke_to(handle, surface.base,
                                    sample.x, sample.y, sample.pressure,
                                    sample.xTilt, sample.yTilt, sample.deltaTime,
                                    sample.viewZoom, sample.viewRotation, sample.barrelRotation,
                                    0)
    }

External links

Exercise

Pick a project of yours that wraps a library doing the hard part: a renderer, a database, a parser, a physics engine. Make two columns. On the left, everything the library gives you. On the right, everything your app owns. Then search your history for any change made inside the library's own files, and for each one write down which side of your border it should have lived on, and how it could get there.
Hint
A clue that the border's in the wrong place: a feature that needed edits on both sides at once. Another: a feature you dropped because "the library doesn't support it" when it was really your app's job all along, the way layers are a painting app's job, not the brush engine's.

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.