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

One Place a Touch Becomes Input

~13 min · apple-pencil, uikit, input, coordinate-spaces, swift

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

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 gets nil, and lesson two shows what nil turns 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.

Convert at the border, once. Every change of space, unit or type between a platform event and your core belongs in one function that everything else calls. Past that line the code speaks one language, and a mistake in the translation has one address.

Code

Support/TouchInput.swift, verbatim (imports omitted): the only door from a UITouch into a brush stroke·swift
extension StylusInput {
    /// The one place a touch becomes the engine's input (architecture: one
    /// Pencil-to-engine mapping), for the canvas and Brush Studio's Drawing
    /// Pad alike: force as a fraction of the device's maximum, the Pencil's
    /// altitude, its azimuth turned into the surface's space (less the view's
    /// `rotation`, reflected when the view is mirrored), its barrel roll, and
    /// the touch's own timestamp. `point` is already in the surface's pixels.
    @MainActor
    init(touch: UITouch, in view: UIView, at point: SIMD2<Double>, rotation: Double = 0, mirrored: Bool = false) {
        var force: Double?
        if touch.maximumPossibleForce > 0, touch.type == .pencil || view.traitCollection.forceTouchCapability == .available {
            force = Double(touch.force / touch.maximumPossibleForce)
        }
        var altitude: Double?
        var azimuth: Double?
        var roll: Double?
        if touch.type == .pencil {
            altitude = Double(touch.altitudeAngle)
            var angle = Double(touch.azimuthAngle(in: view)) - rotation
            if mirrored { angle = .pi - angle }
            azimuth = angle
            roll = Double(touch.rollAngle)
        }
        self.init(x: point.x, y: point.y, timestamp: touch.timestamp, force: force, altitude: altitude,
                  azimuth: azimuth, roll: roll, device: touch.type == .pencil ? .pencil : .finger)
    }
}
A minimal sketch (compiled with swiftc and run): the azimuth rule agrees with the point transform for every rotation and flip, and the swapped order does not·swift
import Foundation

// A minimal sketch: the view transform (canvas -> view) as Ariandel defines it,
// v = translation + R(rotation) * scale * M(c), M flipping x when mirrored.
struct View {
    var scale = 1.0, rotation = 0.0, mirrored = false, canvasWidth = 2048.0
    func viewPoint(_ c: SIMD2<Double>) -> SIMD2<Double> {
        let m = mirrored ? SIMD2(canvasWidth - c.x, c.y) : c
        let s = m * scale
        return SIMD2(cos(rotation) * s.x - sin(rotation) * s.y, sin(rotation) * s.x + cos(rotation) * s.y)
    }
}

// The rule TouchInput.swift applies to a Pencil's azimuth (view space -> canvas space).
func canvasAzimuth(_ viewAngle: Double, rotation: Double, mirrored: Bool) -> Double {
    var angle = viewAngle - rotation
    if mirrored { angle = .pi - angle }
    return angle
}
// The same two steps in the wrong order.
func wrongOrder(_ viewAngle: Double, rotation: Double, mirrored: Bool) -> Double {
    var angle = viewAngle
    if mirrored { angle = .pi - angle }
    return angle - rotation
}

func wrapped(_ a: Double) -> Double { atan2(sin(a), cos(a)) }

var worstRight = 0.0, worstWrong = 0.0
for mirrored in [false, true] {
    for rotationStep in 0..<24 {
        let view = View(scale: 1.7, rotation: Double(rotationStep) * .pi / 12, mirrored: mirrored)
        for directionStep in 0..<36 {
            // A direction on the canvas: where the Pencil's cap points, in canvas space.
            let canvasAngle = wrapped(Double(directionStep) * .pi / 18)
            let p = SIMD2(700.0, 400.0), q = p + SIMD2(cos(canvasAngle), sin(canvasAngle))
            let d = view.viewPoint(q) - view.viewPoint(p)
            let viewAngle = atan2(d.y, d.x)           // what azimuthAngle(in:) reports
            let right = canvasAzimuth(viewAngle, rotation: view.rotation, mirrored: mirrored)
            let wrong = wrongOrder(viewAngle, rotation: view.rotation, mirrored: mirrored)
            worstRight = max(worstRight, abs(wrapped(right - canvasAngle)))
            worstWrong = max(worstWrong, abs(wrapped(wrong - canvasAngle)))
        }
    }
}
print(String(format: "rotate, then reflect: worst error %.2e rad", worstRight))
print(String(format: "reflect, then rotate: worst error %.2f rad", worstWrong))

// Prints:
// rotate, then reflect: worst error 5.15e-13 rad
// reflect, then rotate: worst error 3.14 rad

External links

Exercise

Pick an app of yours that takes pointer, touch or stylus input and supports any view transform (zoom, rotation, flip, a camera in a game). Find every place a raw input event is turned into a model-space value. Move them all into one conversion function, then write a test like the sketch above: build the forward transform, push a known model-space direction through it, convert it back, and assert the round trip for every combination of transform settings.
Hint
Directions and positions need different conversions: a direction ignores translation and scale but not rotation or reflection. If your transform composes steps, undo them in the reverse order, and make the test cover the combined case (rotated and flipped together), because that is where an order mistake hides.

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.