What a Touch Carries
A UITouch from an Apple Pencil is a dense little report: where the tip is, how hard it presses, how far the barrel leans and which way, how far an Apple Pencil Pro has been rolled, and when all of that was true. A painting engine wants nearly the same list, but in its own units and its own space. Ariandel's engine wants a StylusInput: a plain Sendable struct with a canvas position in pixels, a timestamp in seconds, an optional force from 0 to 1, optional altitude, azimuth and roll in radians, and whether a Pencil or a finger made it. Each optional is nil when the device cannot sense that quantity, so "no tilt known" never masquerades as "held upright".
Between the two sits one initializer, StylusInput(touch:in:at:rotation:mirrored:), in the app's Support/TouchInput.swift. It is thirty lines long and @MainActor, because UITouch is. Read it top to bottom (first code block) and you have read every rule a brush stroke's input obeys:
- Force is divided by
maximumPossibleForce, and only when the touch is a Pencil or the screen reports force touch. A finger on an iPad getsnil, and lesson two shows whatnilturns into. - Altitude passes through in radians: π/2 upright, 0 lying flat.
- Azimuth is read in the view's coordinates, then the view's rotation is subtracted and, when the canvas is mirrored, the angle is reflected. The next section is about why.
- Roll passes through as reported. Apple documents 0 for Pencils that cannot sense barrel roll, so for those the value is 0, not
nil. - Timestamp is the touch's own, never the clock at the moment the code runs. Lesson three delivers a whole batch of samples in one call, and each one still carries the instant it was measured.
The position is the one thing this initializer does not convert. Its caller hands it point, already pushed through the inverse of the view transform.
A Direction Is Not a Point
Ariandel's canvas can be zoomed, turned to any angle and flipped left to right, the artist's mirror check. ViewTransform writes the mapping down exactly: a canvas pixel c lands at v = translation + R(rotation) · scale · M(c), where M flips x about the canvas when mirrored. A position goes back through canvasPoint(fromView:), which undoes all of it.
The azimuth is a direction, so translation and scale do not touch it. Rotation and the flip do, and they have to be undone in the reverse of the order they were applied: first take the rotation off (angle − rotation), then undo the flip. A left-right flip sends a direction at angle θ to π − θ. That is the whole of the azimuth rule, and the order is not a style choice. The second code block is a compiled sketch that sweeps 24 rotations, both flips and 36 directions. Done in this order, the recovered angle matches the true canvas direction to within 5.15 × 10−13 radians. Swap the two steps and the worst case is off by π: the tip faces backwards. That error is invisible on an upright canvas and on a canvas that is only rotated. It shows only when the canvas is turned and mirrored, which is exactly the kind of bug a quick test on an upright canvas walks straight past.
The azimuth matters twice downstream. libmypaint turns it into tilt (lesson two), and Ariandel's own surface uses it to turn a picture tip that follows the Pencil's lean. The engine suite checks that last one directly: a tall bar laid by a tap stands up when the Pencil leans up the canvas and lies down when it leans right.
Born in the Canvas, Moved Out When the Pad Arrived
On 2026-10-01, in the commit that created the iPad app (d24155e), these same lines lived inside the canvas view's private input(from:). One consumer, one copy, no problem. The next evening, at 21:07 on 2026-10-02 (8896401), Brush Studio gained a Drawing Pad: a scribble area that paints through a real Painter on an artwork of its own, so a brush being edited behaves exactly as it will on the canvas. A second consumer meant a second chance to copy the conversion, and the commit took the other road: "A touch becomes input in one function now, StylusInput(touch:in:at:), shared by the canvas and the pad." The canvas's input(from:) shrank to a call that passes the view's rotation and mirror flag. The pad, which never turns, passes neither and gets the defaults.
The architecture records it as part of invariant 7, "so the pad feels what the canvas feels," and the house rules put the boundary in five words: "brushes never read UITouch." If a rotation or mirror bug ever appears in Pencil input, it has exactly one place to live.
The Seam Is Also a Test Boundary
StylusInput and its mapping live in the engine package, which imports Foundation, never UIKit, and builds for macOS 15 as well as iOS 18. The UIKit half is an extension that only the app compiles. That split is what lets the engine suite run on a Mac with hand-made inputs: the first-dab tests build seven StylusInputs at force 0.8 and check the pixels. The price of the seam is a limit on what those tests can claim. They prove what the engine does with an input. What a real Pencil reports, they cannot prove. Track 8 makes that limit its whole subject.