collective

Browse / github.com/storytold/vectorcraft / patch

Brushes: Calligraphic and Scatter values follow pen tilt, bearing and rotation

Brushes: Calligraphic and Scatter values follow pen tilt, bearing and rotation ## What and why Part of #764 and #372. On `main`, Brush Options for a Calligraphic or Scatter brush offers Fixed, Random and Pressure, and a pen's tilt and rotation reach no tool. The reference app's [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page also lists Tilt, Bearing and Rotation. `Variation` gains Tilt, Bearing and Rotation, in both dialogs and in `brush.options`. A value on Tilt goes from value − variation with the pen lying flat to value + variation upright. On Bearing or Rotation it adds variation × the angle over 180

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #3945, open)
Base
8622d0c71c18a166d666314058be7f0e3e9b290f
Size
+1603 −349 in 74 files
Review
pending
Shared
2026-10-28
Id
2c4d0c62be945640e592d39f

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/vectorcraft-cli/src/perf.rs apps/vectorcraft-web/src/pencil.rs apps/vectorcraft/src/mac_tablet.rs apps/vectorcraft/src/tablet.rs apps/vectorcraft/src/windows_stylus.rs crates/brush/src/calli.rs crates/brush/src/lib.rs crates/brush/src/tests.rs crates/brush/src/warp.rs crates/doc/src/appearance.rs crates/doc/src/appearance/layers.rs crates/doc/src/appearance/profile.rs crates/doc/src/appearance/tests.rs crates/doc/src/lib.rs crates/engine/src/cmd/brushsym.rs crates/engine/src/cmd/draw2.rs crates/engine/src/cmd/draw2/commands.rs crates/engine/src/cmd/draw2/common.rs crates/engine/src/cmd/draw2/freehand.rs crates/engine/src/cmd/globaledit.rs crates/engine/src/cmd/select.rs crates/engine/src/tests/brushsym.rs crates/engine/src/tests/draw2.rs crates/engine/tests/command_sweep.rs crates/mcp/src/headless.rs crates/mcp/src/tools/definitions.rs crates/tools/src/draw2/anchor/tests.rs crates/tools/src/draw2/gesture.rs crates/tools/src/draw2/gesture/tests.rs crates/tools/src/input.rs crates/tools/src/lib.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/drag.rs crates/ui-egui/src/canvas/grid.rs crates/ui-egui/src/canvas/input.rs crates/ui-egui/src/canvas/tests.rs crates/ui-egui/src/control.rs crates/ui-egui/src/dialogs/brush_art_options.rs crates/ui-egui/src/dialogs/brush_options.rs crates/ui-egui/src/dialogs/brush_options/tests.rs crates/ui-egui/src/dialogs/gradient_stop.rs crates/ui-egui/src/dialogs/tests_screens.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/tr.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/panels/brushes.rs crates/ui-egui/src/stylus.rs docs/development.md docs/gaps.md docs/hardware-parity.md docs/mcp.md docs/target-app-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -137,6 +137,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h). - 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged. - 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged.+- 2026-10-22: Pen tilt, bearing and barrel rotation (part of #764): Calligraphic and Scatter brushes take Tilt, Bearing and Rotation beside Pressure, the Paintbrush records them along each stroke, and the app reads a pen's tilt and an Art Pen's barrel rotation from XInput2 on Linux under X11 and XWayland and from AppKit's tablet events on macOS. Headline numbers unchanged.  - 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs--- a/apps/vectorcraft-cli/src/perf.rs+++ b/apps/vectorcraft-cli/src/perf.rs@@ -116,7 +116,7 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {     let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };     let err = |e: vectorcraft_engine::EngineError| e.to_string();     let mut drag = |down: Point| -> Result<f64, String> {-        let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };+        let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0, pen: None };         s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;         s.select_tool("directSelection", view).map_err(err)?;         let undo = s.doc().map_err(err)?.history.undo.len();@@ -215,7 +215,7 @@ fn deep(n: usize) -> Result<(), String> {         let first = s.doc().map_err(err)?.selection.objects.first().copied();         let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());         if let Some(down) = at {-            let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };+            let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0, pen: None };             s.select_tool("selection", view).map_err(err)?;             s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;             let mut dx = 0.0;diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs--- a/apps/vectorcraft-web/src/pencil.rs+++ b/apps/vectorcraft-web/src/pencil.rs@@ -23,7 +23,7 @@ use std::cell::RefCell; use std::rc::Rc;  use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};-use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed}; use vectorcraft_ui_egui::touch::{self, Source}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast as _;@@ -95,7 +95,7 @@ impl Pointers {      /// A pen moved or pressed with `pressure`, with the end it last pressed with.     fn pen_sample(&self, pressure: f32) {-        self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser }));+        self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser, pose: PenPose::default() }));     }      fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {diff --git a/apps/vectorcraft/src/mac_tablet.rs b/apps/vectorcraft/src/mac_tablet.rs--- a/apps/vectorcraft/src/mac_tablet.rs+++ b/apps/vectorcraft/src/mac_tablet.rs@@ -1,6 +1,7 @@-//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a-//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's-//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).+//! macOS: a drawing tablet's pen pressure (#852), tilt and barrel rotation (#764). winit 0.30+//! hands a pen to the window there as a mouse, without them, so the app reads them from AppKit+//! itself and writes them into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas+//! reads, as on X11 ([`crate::tablet`]). //! //! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with //! each event it is about to dispatch" in this app:@@ -9,9 +10,14 @@ //! it on unchanged: //! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure` //!   (0..1) goes into the feed, kept for the pointer move winit makes of the event-//!   ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event-//!   of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.-//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.+//!   ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. Its `tilt`+//!   (−1..1 on each axis, ±[`TILT_DEGREES`] at the ends) and its `rotation` go into the feed as the+//!   pen's pose, kept for that move too. AppKit gives a rotation of 0 for a pen that does not read+//!   one and a tilt of 0 for an upright pen, so one event cannot tell a pen without tilt or rotation+//!   from one held upright and unturned. Each reads as none until the pen reports a value other+//!   than 0 after it comes near ([`Pen`]).+//!   A mouse event of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.+//! - An `NSEventTypeTabletPoint` event carries the same pressure, tilt and rotation without a move. //! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says //!   which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so, //!   and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck@@ -20,12 +26,14 @@ //! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which //! merges the drags that queue up between two reads into one: every report of the pen arrives. //!-//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the-//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor-//! reads them.+//! The event mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`). The tilt+//! and rotation follow Apple's `NSEvent` documentation, as [`RawEvent::pose`] describes. The mapping+//! works on plain values and is tested on every platform; the monitor reads them. #![cfg_attr(not(target_os = "macos"), allow(dead_code))] -use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};++use crate::tablet::TILT_DEGREES;  /// `NSEventType` values (AppKit headers). mod event_type {@@ -56,6 +64,10 @@ struct RawEvent {     subtype: i16,     /// `pressure` (tablet point events and mouse events of that subtype only).     pressure: f32,+    /// `tilt` (x, y), each −1..1 (the same events as `pressure`).+    tilt: (f64, f64),+    /// `rotation`, in degrees (the same events as `pressure`).+    rotation: f32,     /// `pointingDeviceType` (proximity events only).     device: usize,     /// `isEnteringProximity` (proximity events only).@@ -74,6 +86,40 @@ impl RawEvent {     fn point(&self) -> bool {         self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)     }++    /// The pen's tilt and rotation in [`PenPose`]'s units.+    ///+    /// AppKit's `tilt` is −1.0..1.0 on each axis and 0.0 on both with the pen perpendicular to the+    /// tablet. A negative x is a tilt to the left and a positive x a tilt to the right; a negative y+    /// is a tilt to the top and a positive y a tilt to the bottom+    /// (<https://developer.apple.com/documentation/appkit/nsevent/tilt>). [`PenPose`]'s x is positive+    /// to the right and its y positive down the page, the same signs, so each axis keeps its sign.+    /// Apple gives the ends of the range as maximum tilt and does not give them in degrees+    /// (<https://developer.apple.com/documentation/coregraphics/cgeventfield/tableteventtiltx>), so+    /// ±1.0 is scaled to ±[`TILT_DEGREES`], the degrees the X11 reader ([`crate::tablet`]) gives the+    /// ends of a tilt range that is not in degrees. A value past ±1.0 is clamped.+    ///+    /// AppKit's `rotation` is in degrees, and a device without rotation reports 0.0+    /// (<https://developer.apple.com/documentation/appkit/nsevent/rotation>). Apple's tablet pages give+    /// no direction for rotation; the Cocoa Event Handling Guide gives the `rotation` accessor's+    /// degrees as counterclockwise for the rotate gesture+    /// (<https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/HandlingTouchEvents/HandlingTouchEvents.html>),+    /// and the pen's rotation is taken the same way. [`PenPose`]'s rotation runs clockwise, so the+    /// angle is negated and brought into 0..360.+    ///+    /// The tilt is left out unless `tilts`, and the rotation unless `turns` ([`Pen`]).+    fn pose(&self, tilts: bool, turns: bool) -> PenPose {+        let degrees = |v: f64| (v.clamp(-1.0, 1.0) * TILT_DEGREES) as f32;+        // `0.0 - r` rather than `-r`: a rotation of 0 gives 0, not −0.+        let rotation = turns.then(|| (0.0 - self.rotation).rem_euclid(360.0));+        PenPose { tilt: tilts.then(|| [degrees(self.tilt.0), degrees(self.tilt.1)]), rotation }+    }++    /// Whether the event reports a tilt other than 0, and a rotation other than 0.+    fn reads(&self) -> (bool, bool) {+        let read = |v: f64| v.is_finite() && v != 0.0;+        (read(self.tilt.0) || read(self.tilt.1), read(f64::from(self.rotation)))+    } }  /// The events the monitor reads (`NSEventMask` bits are `1 << type`).@@ -86,7 +132,7 @@ fn event_mask() -> u64 { enum Update {     /// Nothing: an event the monitor doesn't map.     Keep,-    /// The pressure and end of a pen, or `None` for a mouse.+    /// The sample of a pen, or `None` for a mouse.     Set(Option<PenSample>),     /// A pen's sample that comes with a pointer move.     Move(PenSample),@@ -102,10 +148,14 @@ impl Update {     } } -/// Which end of the pen is near the tablet.+/// Which end of the pen is near the tablet, and whether it has reported a tilt or a rotation other+/// than 0 since it came near: until it has, its tilt or rotation reads as none, since AppKit gives+/// 0 for a pen without one and for one held upright or unturned. #[derive(Clone, Copy, Debug, Default, PartialEq)] struct Pen {     eraser: bool,+    tilts: bool,+    turns: bool, }  impl Pen {@@ -113,10 +163,16 @@ impl Pen {         if e.proximity() {             // Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.             self.eraser = e.entering && e.device == device::ERASER;+            (self.tilts, self.turns) = (false, false);             let pen = e.entering && e.device != device::CURSOR;-            return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));+            return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser, ..PenSample::default() }));+        }+        if e.point() {+            let (tilts, turns) = e.reads();+            self.tilts |= tilts;+            self.turns |= turns;         }-        let sample = PenSample { pressure: e.pressure, eraser: self.eraser };+        let sample = PenSample { pressure: e.pressure, eraser: self.eraser, pose: e.pose(self.tilts, self.turns) };         match (e.point(), e.mouse()) {             (true, true) => Update::Move(sample),             (true, false) => Update::Set(Some(sample)),@@ -187,6 +243,9 @@ mod monitor {         }         if raw.point() {             raw.pressure = e.pressure();+            let tilt = e.tilt();+            raw.tilt = (tilt.x, tilt.y);+            raw.rotation = e.rotation();         }         if raw.proximity() {             raw.device = e.pointingDeviceType().0;@@ -211,8 +270,14 @@ mod tests {         RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }     } +    /// A sample of the tip of a pen that has reported no tilt or rotation.     fn tip(pressure: f32) -> PenSample {-        PenSample { pressure, eraser: false }+        PenSample { pressure, eraser: false, pose: PenPose::default() }+    }++    /// A sample of the pen near the tablet but not yet read: no pose.+    fn near(eraser: bool) -> PenSample {+        PenSample { pressure: 0.0, eraser, pose: PenPose::default() }     }      #[test]@@ -240,16 +305,45 @@ mod tests {     #[test]     fn proximity_says_which_end_is_near() {         let mut p = Pen::default();-        let eraser = |pressure| PenSample { pressure, eraser: true };-        assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));+        let eraser = |pressure| PenSample { pressure, eraser: true, pose: PenPose::default() };+        assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(near(true))));         assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));         assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");         assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));-        assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");-        assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");+        assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(near(false))), "the tip");+        assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(near(false))), "an unknown tool draws as the tip");         assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");         let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };-        assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");+        assert_eq!(p.handle(&e), Update::Set(Some(near(true))), "as a mouse event's subtype");+    }++    /// #764: AppKit's tilt (−1..1, x positive to the right, y positive to the bottom) reaches the+    /// feed in degrees with the same signs, and its rotation, taken as counterclockwise, reaches it+    /// clockwise.+    #[test]+    fn a_pens_tilt_and_rotation_go_into_the_feed() {+        let mut p = Pen::default();+        let sample = |pressure, pose| PenSample { pressure, eraser: false, pose };+        assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)), "no tilt or rotation reported yet");+        let leaning = RawEvent { tilt: (0.5, -0.25), rotation: 30.0, ..pen(DRAG, 0.5) };+        let pose = PenPose { tilt: Some([30.0, -15.0]), rotation: Some(330.0) };+        assert_eq!(p.handle(&leaning), Update::Move(sample(0.5, pose)));+        let past_the_ends = RawEvent { tilt: (-4.0, 1.5), rotation: 270.0, ..pen(event_type::TABLET_POINT, 0.8) };+        let pose = PenPose { tilt: Some([-60.0, 60.0]), rotation: Some(90.0) };+        assert_eq!(p.handle(&past_the_ends), Update::Set(Some(sample(0.8, pose))), "clamped");+        let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };+        let Update::Move(upright) = p.handle(&pen(DRAG, 0.6)) else { panic!("a pen's drag is a move") };+        assert_eq!(upright, sample(0.6, pose), "once reported, 0 is upright and unturned");+        assert!(upright.pose.rotation.is_some_and(f32::is_sign_positive), "{upright:?}");+        let feed = StylusFeed::default();+        p.handle(&RawEvent { tilt: (f64::NAN, 0.0), rotation: -90.0, ..pen(DRAG, 0.3) }).apply(&feed);+        assert_eq!(feed.pose(), Some(PenPose { tilt: None, rotation: Some(90.0) }), "a tilt that is not a number is left out");+        p.handle(&proximity(1, true));+        let tilted = RawEvent { tilt: (0.0, 0.5), ..pen(event_type::TABLET_POINT, 0.8) };+        let pose = PenPose { tilt: Some([0.0, 30.0]), rotation: None };+        assert_eq!(p.handle(&tilted), Update::Set(Some(sample(0.8, pose))), "no rotation reported since the pen came near");+        p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);+        assert_eq!(feed.pose(), None, "a mouse moved");     }      #[test]@@ -259,7 +353,8 @@ mod tests {         for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {             p.handle(&e).apply(&feed);         }-        assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));+        let pressures: Vec<f32> = feed.take_moves().into_iter().map(|(p, _)| p).collect();+        assert_eq!((pressures, feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));         p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);         assert_eq!(feed.held(), None, "a mouse moved");     }diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs--- a/apps/vectorcraft/src/tablet.rs+++ b/apps/vectorcraft/src/tablet.rs@@ -1,17 +1,24 @@-//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a-//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from-//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas-//! reads.+//! Linux: a drawing tablet's pen pressure, tilt and barrel rotation under X11, XWayland included+//! (#764). winit 0.30 hands a pen to the window there as a mouse, without any of them, so the app+//! reads them from XInput2 itself and writes them into the UI's [`StylusFeed`]+//! (`Services::stylus`), which the canvas reads. //! //! The reader opens a second connection to the X server (x11rb's `RustConnection`, without //! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw //! events reach every client that selects them, also while winit holds the pointer grab of a //! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen //! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label),-//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the-//! valuator's range to 0..1, goes into the feed. Any other pointer-//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so-//! the last pressure of each pen is kept.+//! unless its XInput type is a touchpad's or a touchscreen's. Its valuators go into the feed:+//!+//! - "Abs Pressure", scaled from the valuator's range to 0..1;+//! - "Abs Tilt X" and "Abs Tilt Y" (a pen that has both), in degrees as they are when the range+//!   fits in ±90, otherwise scaled to ±[`TILT_DEGREES`];+//! - "Abs Rotary Z" (the barrel rotation of an Art Pen), scaled to a turn with the middle of the+//!   range at 0°.+//!+//! Any other pointer device moving sets the feed to `None`. Raw events carry only the valuators+//! that changed, so the last value of each axis of each pen is kept; an axis the pen has that no+//! event has carried yet reads 0 (upright, unturned), and an axis it lacks reads nothing. //! //! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains //! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's@@ -22,13 +29,18 @@ //! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland //! that receives none of the window's input. //!-//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with-//! the pressure axis only.+//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`).+//! PhotoCraft maps the rotation to 0..360° and reads an axis a pen lacks as 0; here the middle of+//! the rotation's range is 0°, and an axis a pen lacks reads nothing. #![cfg_attr(not(target_os = "linux"), allow(dead_code))]  use std::collections::HashMap; -use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};++/// The tilt of a valuator whose range does not fit in ±90 (so does not give degrees), at the ends+/// of its range.+pub(crate) const TILT_DEGREES: f64 = 60.0;  /// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer /// than 16).@@ -41,33 +53,74 @@ const MAX_DEVICES: usize = 256; /// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens. const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"]; -/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range.+/// Which pen axis a valuator carries.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum AxisKind {+    Pressure,+    TiltX,+    TiltY,+    Rotation,+}++impl AxisKind {+    /// The kind of the valuator labeled `label`, if it is one of a pen's.+    fn from_label(label: &str) -> Option<Self> {+        match label {+            "Abs Pressure" => Some(Self::Pressure),+            "Abs Tilt X" => Some(Self::TiltX),+            "Abs Tilt Y" => Some(Self::TiltY),+            "Abs Rotary Z" => Some(Self::Rotation),+            _ => None,+        }+    }++    /// Where [`Pens`] keeps the last value of this axis.+    fn slot(self) -> usize {+        match self {+            Self::Pressure => 0,+            Self::TiltX => 1,+            Self::TiltY => 2,+            Self::Rotation => 3,+        }+    }+}++/// A valuator of a pen: what it carries, its number in a raw event's valuator mask, and its range. #[derive(Clone, Copy, Debug, PartialEq)] struct Axis {+    kind: AxisKind,     number: u16,     min: f64,     max: f64, }  impl Axis {-    /// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a-    /// mouse's, 1.-    fn pressure(&self, v: f64) -> f32 {+    /// A raw value of this valuator as a pressure of 0..1, a tilt in degrees (−90..90) or a+    /// rotation in degrees (−180..180, the middle of the range at 0). A value that is not a number+    /// is a mouse's pressure, 1, or a tilt or rotation of 0.+    fn map(&self, v: f64) -> f32 {         if !v.is_finite() {-            return 1.0;+            return if self.kind == AxisKind::Pressure { 1.0 } else { 0.0 };         }         let span = self.max - self.min;         let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v };-        unit.clamp(0.0, 1.0) as f32+        let unit = unit.clamp(0.0, 1.0);+        match self.kind {+            AxisKind::Pressure => unit as f32,+            AxisKind::TiltX | AxisKind::TiltY if self.min >= -90.0 && self.max <= 90.0 && span > 0.0 => v.clamp(-90.0, 90.0) as f32,+            AxisKind::TiltX | AxisKind::TiltY => ((unit * 2.0 - 1.0) * TILT_DEGREES) as f32,+            AxisKind::Rotation => ((unit - 0.5) * 360.0) as f32,+        }     } }  /// A pointer device the X server lists.-#[derive(Clone, Copy, Debug, PartialEq)]+#[derive(Clone, Debug, PartialEq)] struct Device {     id: u16,-    /// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none.-    pressure: Option<Axis>,+    /// The pen's valuators: a pen has a pressure valuator, a mouse, a touchpad or a touchscreen+    /// has none (and keeps none).+    axes: Vec<Axis>,     /// The eraser end of a pen: its name or its XInput type contains "eraser", in any case.     eraser: bool, }@@ -77,14 +130,31 @@ impl Device {     /// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`).     fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator<Item = (u16, String, f64, f64)>) -> Self {         let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k));-        let pressure = valuators-            .into_iter()-            .filter(|(number, ..)| *number < MAX_AXES)-            .find(|(_, label, ..)| label == "Abs Pressure")-            .map(|(number, _, min, max)| Axis { number, min, max })-            .filter(|_| !finger);+        let mut axes: Vec<Axis> = Vec::new();+        for (number, label, min, max) in valuators {+            // The first valuator of each kind.+            if let Some(kind) = AxisKind::from_label(&label)+                && number < MAX_AXES+                && !axes.iter().any(|a| a.kind == kind)+            {+                axes.push(Axis { kind, number, min, max });+            }+        }+        if finger || !axes.iter().any(|a| a.kind == AxisKind::Pressure) {+            axes.clear();+        }         let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser"));-        Self { id, pressure, eraser }+        Self { id, axes, eraser }+    }++    /// The valuator of `kind`, if the device is a pen that has one.+    fn axis(&self, kind: AxisKind) -> Option<&Axis> {+        self.axes.iter().find(|a| a.kind == kind)+    }++    /// A pen: a device with a pressure valuator.+    fn is_pen(&self) -> bool {+        !self.axes.is_empty()     } } @@ -93,15 +163,15 @@ impl Device { enum Update {     /// Nothing: a device the last listing didn't have (a master, or one added since).     Keep,-    /// The pressure and end of a pen, or `None` for another pointer.+    /// The pressure, end, tilt and rotation of a pen, or `None` for another pointer.     Set(Option<PenSample>), } -/// The pointer devices and the last pressure of each pen.+/// The pointer devices and the last pressure, tilt and rotation of each pen. #[derive(Debug, Default)] struct Pens {     devices: HashMap<u16, Device>,-    last: HashMap<u16, f32>,+    last: HashMap<u16, [Option<f32>; 4]>, }  impl Pens {@@ -114,15 +184,22 @@ impl Pens {     /// A raw motion or button event of device `id`, with the valuators that changed.     fn handle(&mut self, id: u16, valuators: impl IntoIterator<Item = (u16, f64)>) -> Update {         let Some(device) = self.devices.get(&id) else { return Update::Keep };-        let Some(axis) = device.pressure else { return Update::Set(None) };-        let eraser = device.eraser;-        let last = self.last.entry(id).or_insert(0.0);+        if !device.is_pen() {+            return Update::Set(None);+        }+        let last = self.last.entry(id).or_default();         for (number, v) in valuators {-            if number == axis.number {-                *last = axis.pressure(v);+            for axis in device.axes.iter().filter(|a| a.number == number) {+                if let Some(slot) = last.get_mut(axis.kind.slot()) {+                    *slot = Some(axis.map(v));+                }             }         }-        Update::Set(Some(PenSample { pressure: *last, eraser }))+        // An axis the pen has reads 0 until an event carries it; one it lacks reads nothing.+        let value = |kind: AxisKind| device.axis(kind).map(|_| last.get(kind.slot()).copied().flatten().unwrap_or(0.0));+        let tilt = value(AxisKind::TiltX).zip(value(AxisKind::TiltY)).map(|(x, y)| [x, y]);+        let pose = PenPose { tilt, rotation: value(AxisKind::Rotation) };+        Update::Set(Some(PenSample { pressure: value(AxisKind::Pressure).unwrap_or(0.0), eraser: device.eraser, pose }))     }      /// A raw event as the X server sends it: its valuator mask and the values of the valuators@@ -149,8 +226,8 @@ fn fp3232(integral: i32, frac: u32) -> f64 {     f64::from(integral) + f64::from(frac) / 4_294_967_296.0 } -/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None`-/// (logged) when the X server can't be reached or has no XInput 2.1.+/// Start reading pen pressure, tilt and rotation for a window on X11: the feed the reader writes+/// into, or `None` (logged) when the X server can't be reached or has no XInput 2.1. #[cfg(target_os = "linux")] pub fn start() -> Option<StylusFeed> {     let feed = StylusFeed::default();@@ -224,7 +301,7 @@ mod x11 {         }     } -    /// The slave pointer devices with their names and pressure valuators (none on any error).+    /// The slave pointer devices with their names and pen valuators (none on any error).     fn query_devices(conn: &RustConnection) -> Vec<Device> {         let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };         let kinds = device_kinds(conn);@@ -265,14 +342,16 @@ mod x11 { mod tests {     use super::*; -    /// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to-    /// 65,535.+    /// The valuators of a Wacom pen as the X server lists them: x and y, pressure from 0 to+    /// 65,535, tilt in degrees, and an Art Pen's barrel rotation.     fn pen_valuators() -> Vec<(u16, String, f64, f64)> {         vec![             (0, "Abs X".to_string(), 0.0, 44704.0),             (1, "Abs Y".to_string(), 0.0, 27940.0),             (2, "Abs Pressure".to_string(), 0.0, 65535.0),             (3, "Abs Tilt X".to_string(), -64.0, 63.0),+            (4, "Abs Tilt Y".to_string(), -64.0, 63.0),+            (5, "Abs Rotary Z".to_string(), -900.0, 899.0),         ]     } @@ -295,19 +374,22 @@ mod tests {      #[test]     fn a_device_with_an_abs_pressure_valuator_is_a_pen() {-        assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 }));-        assert_eq!(mouse(12).pressure, None);+        assert_eq!(pen(9).axis(AxisKind::Pressure), Some(&Axis { kind: AxisKind::Pressure, number: 2, min: 0.0, max: 65535.0 }));+        assert_eq!(pen(9).axes.len(), 4, "pressure, tilt and rotation");+        assert!(!mouse(12).is_pen());         let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);-        assert_eq!(pad.pressure, None, "a tablet's pad is no pen");+        assert!(!pad.is_pen() && pad.axes.is_empty(), "a tablet's pad is no pen");         let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);-        assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored");+        assert!(!far.is_pen(), "valuator numbers past the limit are ignored");+        let tilt_only = Device::new(14, "", Some("STYLUS"), [(3, "Abs Tilt X".to_string(), -64.0, 63.0), (4, "Abs Tilt Y".to_string(), -64.0, 63.0)]);+        assert!(!tilt_only.is_pen() && tilt_only.axes.is_empty(), "no pressure, no pen");         // The types libinput and XWayland give pens, and a device whose type wasn't listed.         for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] {-            assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}");+            assert!(Device::new(15, "", kind, pen_valuators()).is_pen(), "{kind:?}");         }         // xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure".         for kind in ["TOUCHPAD", "TOUCHSCREEN"] {-            assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}");+            assert!(Device::new(16, "", Some(kind), pen_valuators()).axes.is_empty(), "{kind}");         }     } @@ -324,7 +406,7 @@ mod tests {         ];         for (name, kind) in erasers {             let d = Device::new(10, name, kind, pen_valuators());-            assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})");+            assert!(d.eraser && d.is_pen(), "{name} ({kind:?})");         }         let tips = [             ("Wacom Intuos Pro M Pen stylus", Some("STYLUS")),@@ -343,13 +425,14 @@ mod tests {         let mut pens = Pens::default();         pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]);         let feed = StylusFeed::default();+        let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };         pens.raw_event(&feed, 9, &[0b100], &[32767.5]);-        assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false })));+        assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false, pose })));         assert_eq!(feed.eraser(), Some(false));         // Turned over, the pen hovers with its eraser end: the eraser device's first event, with         // no pressure yet.         pens.raw_event(&feed, 10, &[0b1], &[100.0]);-        assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true })));+        assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true, pose })));         assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0)));         pens.raw_event(&feed, 10, &[0b100], &[65535.0]);         assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));@@ -361,6 +444,55 @@ mod tests {         assert_eq!(feed.eraser(), None);     } +    /// #764: a pen's tilt goes into the feed in degrees and its barrel rotation as a turn, the+    /// middle of the valuator's range at 0°; a pen without those valuators reads neither.+    #[test]+    fn a_pens_tilt_and_rotation_go_into_the_feed() {+        let mut pens = Pens::default();+        let pressure_only = Device::new(10, "", Some("STYLUS"), pen_valuators().into_iter().take(3));+        let one_tilt = Device::new(11, "", Some("STYLUS"), pen_valuators().into_iter().take(4));+        pens.set_devices([pen(9), pressure_only, one_tilt, mouse(12)]);+        let feed = StylusFeed::default();+        // Pressure, tilt x, tilt y and rotation: valuators 2 to 5.+        pens.raw_event(&feed, 9, &[0b11_1100], &[32767.5, -30.0, 45.0, 0.0]);+        let pose = feed.pose().unwrap();+        assert_eq!((feed.held(), pose.tilt), (Some(0.5), Some([-30.0, 45.0])));+        assert!(pose.rotation.is_some_and(|r| r < 0.2), "{pose:?}");+        // A quarter of the range back is a quarter turn back; the tilt stays as it was.+        pens.raw_event(&feed, 9, &[0b10_0000], &[-450.0]);+        let pose = feed.pose().unwrap();+        assert_eq!(pose.tilt, Some([-30.0, 45.0]));+        assert!(pose.rotation.is_some_and(|r| (r - 270.0).abs() < 0.2), "{pose:?}");+        // A pen with no tilt or rotation valuators, or with one tilt valuator only, reads none.+        pens.raw_event(&feed, 10, &[0b100], &[65535.0]);+        assert_eq!((feed.held(), feed.pose()), (Some(1.0), None));+        pens.raw_event(&feed, 11, &[0b1100], &[65535.0, 20.0]);+        assert_eq!(feed.pose(), None);+        // The mouse: none.+        pens.raw_event(&feed, 12, &[0b1], &[3.0]);+        assert_eq!(feed.pose(), None);+        // A pen whose tilt and rotation no event has carried yet is upright and unturned.+        let mut fresh = Pens::default();+        fresh.set_devices([pen(9)]);+        let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };+        assert_eq!(fresh.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright })));+    }++    /// Tilt valuators whose range does not fit in ±90 are scaled to ±60°.+    #[test]+    fn tilt_ranges_that_are_not_degrees_are_scaled() {+        let d = Device::new(+            1,+            "",+            None,+            [(2, "Abs Pressure".into(), 0.0, 1023.0), (3, "Abs Tilt X".into(), 0.0, 127.0), (4, "Abs Tilt Y".into(), -1000.0, 1000.0)],+        );+        let mut pens = Pens::default();+        pens.set_devices([d]);+        let Update::Set(Some(PenSample { pose, .. })) = pens.handle(1, [(3, 127.0), (4, -1000.0)]) else { panic!("no pen") };+        assert_eq!(pose.tilt, Some([60.0, -60.0]));+    }+     #[test]     fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() {         let mut pens = Pens::default();@@ -389,13 +521,19 @@ mod tests {      #[test]     fn hostile_device_data_gives_a_pressure_within_0_to_1() {-        let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)];+        let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN), (90.0, -90.0)];         for (min, max) in ranges {             let mut pens = Pens::default();-            pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]);+            let labels = ["Abs Pressure", "Abs Tilt X", "Abs Tilt Y", "Abs Rotary Z"];+            pens.set_devices([Device::new(1, "", None, (2..).zip(labels).map(|(n, l)| (n, l.to_string(), min, max)))]);             for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] {-                let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };+                let Update::Set(Some(PenSample { pressure: p, pose, .. })) = pens.handle(1, (2..6).map(|n| (n, v))) else {+                    panic!("{min}..{max}: {v} gave no pressure")+                };                 assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}");+                let tilt = pose.tilt.unwrap_or_default();+                assert!(tilt.iter().all(|t| (-90.0..=90.0).contains(t)), "{min}..{max}: {v} gave {pose:?}");+                assert!(pose.rotation.is_some_and(|r| (-180.0..=180.0).contains(&r)), "{min}..{max}: {v} gave {pose:?}");             }         }         let mut pens = Pens::default();diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs--- a/apps/vectorcraft/src/windows_stylus.rs+++ b/apps/vectorcraft/src/windows_stylus.rs@@ -22,7 +22,7 @@ use std::cell::RefCell; use std::sync::OnceLock;  use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};-use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};  /// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's /// `wParam`, a pen in contact with its barrel button pressed.@@ -114,7 +114,7 @@ fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) ->         _ if in_contact => 1.0,         _ => 0.0,     };-    PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 }+    PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() } }  /// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,@@ -403,7 +403,11 @@ mod tests {     fn a_pens_pressure_is_the_one_winit_gives_its_touch() {         assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5);         assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0);-        assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), PenSample { pressure: 0.0, eraser: true }, "hovering");+        assert_eq!(+            pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false),+            PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() },+            "hovering"+        );         assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully");         assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one");         assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0");@@ -420,7 +424,7 @@ mod tests {      #[test]     fn pen_messages_set_the_feed_and_other_pointers_clear_it() {-        let pen = PenSample { pressure: 0.0, eraser: true };+        let pen = PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() };         // `POINTER_MESSAGE_FLAG_INRANGE` in the high word of `wParam`, pointer 7 in the low word.         let in_range = (POINTER_MESSAGE_FLAG_INRANGE << 16) | 7;         let no_mouse = || -> bool { panic!("a pointer message is not a mouse message") };diff --git a/crates/brush/src/calli.rs b/crates/brush/src/calli.rs--- a/crates/brush/src/calli.rs+++ b/crates/brush/src/calli.rs@@ -5,16 +5,16 @@ //! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the //! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`. //!-//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen-//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each-//! sample takes the nib for the pressure there.+//! A Calligraphic brush with Pressure, Tilt, Bearing or Rotation variation changes its nib along+//! the stroke with what the pen recorded on it (`StrokeLayer::pressure`, `tilt`, `bearing` and+//! `rotation`): the sweep is sampled densely and each sample takes the nib for the pen there.  use vectorcraft_color::Paint;-use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};+use vectorcraft_doc::{Node, StrokeLayer}; use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};  use crate::track::{Rng, Track, normal, seed_of, tracks};-use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};+use crate::{Bristle, BristleShape, Calligraphic, PenAt, Variation, filled, tolerance};  /// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples /// it takes along a whole subpath.@@ -127,18 +127,16 @@ fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {     [angle, round.clamp(1.0, 100.0), size.max(0.05)] } -/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from-/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),-/// kept within its range; the others are `base`'s.-fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {+/// The nib's angle, roundness and size where the pen recorded `pen`: each value on a mode that+/// follows the pen goes from the brush's value less its variation (where the pen reads 0 for it)+/// to the value plus the variation (where it reads 1), the angle turning round past ±180° and the+/// others kept within their ranges; the other values are `base`'s.+fn pressured(c: &Calligraphic, base: [f64; 3], pen: PenAt) -> [f64; 3] {     let values = [c.angle, c.roundness, c.size];     let mut out = base;     for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {-        let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };-        if mode == Variation::Pressure {-            let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);-            *v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };-        }+        let (Some(&value), Some(&var), Some(p)) = (values.get(i), c.variation.get(i), pen.control(mode)) else { continue };+        *v = Calligraphic::moved(i, value, var * (2.0 * p - 1.0));     }     let [angle, round, size] = out;     [angle, round.clamp(1.0, 100.0), size.max(0.05)]@@ -147,14 +145,13 @@ fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] { pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {     let mut rng = Rng::new(seed_of(bp, name));     let base = randomized(c, &mut rng);-    let subs: Vec<SubPath> = if c.uses_pressure() {-        let pressure = stroke.pressure.as_ref();+    let subs: Vec<SubPath> = if c.uses_pen() {         let nib = |f: f64| {-            let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));+            let [angle, round, size] = pressured(c, base, PenAt::of(stroke, f));             Nib::new(angle, round / 100.0, size * weight)         };-        // Flattened for the largest nib the pressure gives.-        let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;+        // Flattened for the largest nib the pen gives.+        let size = pressured(c, base, PenAt::uniform(0.0))[2].max(pressured(c, base, PenAt::uniform(1.0))[2]) * weight;         tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()     } else {         let [angle, round, size] = base;diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs--- a/crates/brush/src/lib.rs+++ b/crates/brush/src/lib.rs@@ -159,6 +159,24 @@ impl Calligraphic {     pub fn uses_pressure(&self) -> bool {         self.modes().contains(&Variation::Pressure)     }+    /// Does the pen (its pressure, tilt, bearing or rotation) change the nib?+    pub fn uses_pen(&self) -> bool {+        self.modes().iter().any(|m| m.follows_pen())+    }+    /// Value `i` (0 angle, 1 roundness, 2 size) moved by `by`: the angle turns round past ±180°,+    /// roundness and size stay within their [`Self::RANGES`]. A result that is not a number is+    /// `value`, kept within its range.+    pub fn moved(i: usize, value: f64, by: f64) -> f64 {+        let (lo, hi) = Self::RANGES.get(i).copied().unwrap_or((f64::MIN, f64::MAX));+        let x = value + by;+        if !x.is_finite() {+            value.clamp(lo, hi)+        } else if i == 0 {+            vectorcraft_geom::normalize_deg(x)+        } else {+            x.clamp(lo, hi)+        }+    } }  /// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new@@ -174,16 +192,29 @@ pub enum Variation {     /// From the value less the variation at the lightest pen pressure to the value plus the     /// variation at the heaviest.     Pressure,+    /// From the value less the variation with the pen lying flat to the value plus the variation+    /// with it upright.+    Tilt,+    /// The value plus the variation times the pen's bearing over 180°: the bearing is which way the+    /// pen leans, counter-clockwise on the page from leaning to the right (0°) round to ±180°.+    Bearing,+    /// The value plus the variation times the pen barrel's rotation over 180°, counter-clockwise+    /// from where the pen reports 0° round to ±180°.+    Rotation, }  impl Variation {-    pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];+    pub const ALL: [Variation; 6] =+        [Variation::Fixed, Variation::Random, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation];     /// The name brush definitions use.     pub fn id(self) -> &'static str {         match self {             Variation::Fixed => "fixed",             Variation::Random => "random",             Variation::Pressure => "pressure",+            Variation::Tilt => "tilt",+            Variation::Bearing => "bearing",+            Variation::Rotation => "rotation",         }     }     pub fn label(self) -> &'static str {@@ -191,11 +222,63 @@ impl Variation {             Variation::Fixed => "Fixed",             Variation::Random => "Random",             Variation::Pressure => "Pressure",+            Variation::Tilt => "Tilt",+            Variation::Bearing => "Bearing",+            Variation::Rotation => "Rotation",         }     }     pub fn parse(id: &str) -> Option<Self> {         Self::ALL.into_iter().find(|v| v.id() == id)     }+    /// Does the value follow something the pen records along a stroke (Pressure, Tilt, Bearing,+    /// Rotation)?+    pub fn follows_pen(self) -> bool {+        matches!(self, Variation::Pressure | Variation::Tilt | Variation::Bearing | Variation::Rotation)+    }+}++/// What the pen recorded at one point of a stroke, each as the 0..1 that [`Variation`]'s pen modes+/// read: 0 gives the value less the variation (a Scatter brush's first value), 1 the value plus it+/// (the second value), and ½ the value itself, as a stroke without that recording does.+#[derive(Clone, Copy, Debug, PartialEq)]+pub(crate) struct PenAt {+    /// The pressure, lightest 0.+    pressure: f64,+    /// 1 upright, 0 lying flat.+    tilt: f64,+    /// ½ plus the bearing over 360°.+    bearing: f64,+    /// ½ plus the counter-clockwise barrel rotation over 360°.+    rotation: f64,+}++impl PenAt {+    /// Every input at `c`.+    pub(crate) fn uniform(c: f64) -> Self {+        Self { pressure: c, tilt: c, bearing: c, rotation: c }+    }+    /// What `stroke` recorded at `f` (the fraction of its path's length).+    pub(crate) fn of(stroke: &StrokeLayer, f: f64) -> Self {+        let mid = vectorcraft_doc::PressureProfile::MID;+        let turn = |p: Option<&vectorcraft_doc::AngleProfile>| 0.5 + p.map_or(0.0, |p| p.at(f)) / 360.0;+        Self {+            pressure: stroke.pressure.as_ref().map_or(mid, |p| p.at(f)),+            tilt: 1.0 - stroke.tilt.as_ref().map_or(mid, |p| p.at(f)),+            bearing: turn(stroke.bearing.as_ref()),+            rotation: turn(stroke.rotation.as_ref()),+        }+    }+    /// The 0..1 a value on `mode` takes, or `None` for a mode that doesn't follow the pen.+    pub(crate) fn control(&self, mode: Variation) -> Option<f64> {+        let c = match mode {+            Variation::Fixed | Variation::Random => return None,+            Variation::Pressure => self.pressure,+            Variation::Tilt => self.tilt,+            Variation::Bearing => self.bearing,+            Variation::Rotation => self.rotation,+        };+        Some(if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 })+    } }  /// Scatter brush: copies of the art along the path.@@ -216,8 +299,10 @@ pub struct Scatter {     pub colorization: Colorization,     /// How size, spacing, scatter and rotation vary between their two values: Fixed takes the     /// first, Random any value between them, Pressure the first at the lightest pen pressure and-    /// the second at the heaviest. `None` (brushes saved before there were modes): Random where-    /// the two differ, else Fixed — see [`Scatter::modes`].+    /// the second at the heaviest, Tilt the first with the pen lying flat and the second upright,+    /// Bearing and Rotation the first at −180° and the second at 180° (half way at 0°). `None`+    /// (brushes saved before there were modes): Random where the two differ, else Fixed (see+    /// [`Scatter::modes`]).     #[serde(skip_serializing_if = "Option::is_none")]     pub modes: Option<[Variation; 4]>, }@@ -628,7 +713,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok         BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),         BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),         BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),-        BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),+        BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name),         BrushKind::Pattern(p) => warp::pattern(p, bp, w),     };     let colorization = brush.kind.colorization();diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs--- a/crates/brush/src/tests.rs+++ b/crates/brush/src/tests.rs@@ -496,6 +496,127 @@ fn pressure_varies_angle_and_roundness() {     };     assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));     assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));+    // The angle turns on past 180°: 170 + 40 is 210°, the nib at 30° (not stopped at 180°).+    let turned = Calligraphic { angle: 170.0, roundness: 10.0, variation: [40.0, 0.0, 0.0], ..c([Pressure, Fixed, Fixed]) };+    let expected = 2.0 * (25.0 * 0.25 + 0.25 * 0.75f64).sqrt();+    let h = draw(turned, 1.0);+    assert!(near(h, expected, 0.05), "{h} vs {expected}");+}++/// A stroke that recorded the pen's tilt (`tilt`: 0 upright to 1 flat), bearing and barrel+/// rotation (degrees counter-clockwise), each the same all along it where given.+fn posed(tilt: Option<f64>, bearing: Option<f64>, rotation: Option<f64>) -> StrokeLayer {+    let flat = |v: f64| vec![(0.0, v), (1.0, v)];+    StrokeLayer {+        tilt: tilt.map(|t| vectorcraft_doc::PressureProfile { points: flat(t) }),+        bearing: bearing.map(|b| vectorcraft_doc::AngleProfile { points: flat(b) }),+        rotation: rotation.map(|r| vectorcraft_doc::AngleProfile { points: flat(r) }),+        ..stroke(1.0)+    }+}++/// #764: with Roundness on Tilt the nib goes from roundness − variation with the pen lying flat to+/// roundness + variation with it upright, along the stroke as the pen leaned.+#[test]+fn tilt_maps_to_roundness_along_the_stroke() {+    let c = Calligraphic {+        roundness: 50.0,+        size: 10.0,+        variation: [0.0, 50.0, 0.0],+        modes: Some([Variation::Fixed, Variation::Tilt, Variation::Fixed]),+        ..Default::default()+    };+    let b = Brush { name: "t".into(), kind: BrushKind::Calligraphic(c) };+    let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };+    let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &upright_to_flat);+    // Upright: round (half 5); half way: roundness 50 (half 2.5); flat: a hairline.+    assert!(near(half_width(&out, 0.5, 1.5), 5.0, 0.1), "{}", half_width(&out, 0.5, 1.5));+    assert!(near(half_width(&out, 49.5, 50.5), 2.5, 0.1), "{}", half_width(&out, 49.5, 50.5));+    assert!(half_width(&out, 98.5, 99.5) < 0.2, "{}", half_width(&out, 98.5, 99.5));+}++/// #764: with Angle on Bearing or on Rotation and a variation of 180°, the nib turns with the way+/// the pen leans or with its barrel.+#[test]+fn bearing_and_rotation_turn_the_nib() {+    let horizontal = line(0.0, 0.0, 100.0, 0.0);+    let height = |mode: Variation, st: &StrokeLayer| {+        let c = Calligraphic {+            roundness: 10.0,+            size: 10.0,+            variation: [180.0, 0.0, 0.0],+            modes: Some([mode, Variation::Fixed, Variation::Fixed]),+            ..Default::default()+        };+        let b = Brush { name: "b".into(), kind: BrushKind::Calligraphic(c) };+        bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, st)).height()+    };+    // Leaning right (0°): the nib lies along the stroke (1 pt across); leaning up the page (90°)+    // or down it (−90°), the nib stands across it (10 pt).+    assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(0.0), None)), 1.0, 0.05));+    assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(90.0), None)), 10.0, 0.05));+    assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(-90.0), None)), 10.0, 0.05));+    // The barrel turned a quarter turn stands it across the stroke too.+    assert!(near(height(Variation::Rotation, &posed(None, None, Some(0.0))), 1.0, 0.05));+    assert!(near(height(Variation::Rotation, &posed(None, None, Some(90.0))), 10.0, 0.05));+    // Each reads only its own recording.+    assert!(near(height(Variation::Rotation, &posed(None, Some(90.0), None)), 1.0, 0.05));+    assert!(near(height(Variation::Bearing, &posed(None, None, Some(90.0))), 1.0, 0.05));+}++/// A stroke without the pen's tilt, bearing or rotation (a mouse, a pen without that input, or a+/// path the brush was applied to) draws Tilt, Bearing and Rotation values at the brush's own value.+#[test]+fn pen_modes_without_a_recording_draw_the_value_itself() {+    let horizontal = line(0.0, 0.0, 100.0, 0.0);+    for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] {+        let c = Calligraphic { angle: 90.0, roundness: 50.0, size: 10.0, variation: [90.0, 50.0, 6.0], modes: Some([mode; 3]) };+        let b = Brush { name: "n".into(), kind: BrushKind::Calligraphic(c) };+        let h = bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &stroke(1.0))).height();+        assert!(near(h, 10.0, 0.05), "{mode:?}: {h}");+        let sc = Scatter {+            size: (50.0, 300.0),+            modes: Some([mode, Variation::Fixed, Variation::Fixed, Variation::Fixed]),+            ..scatter(&scatter_brush(100.0))+        };+        let s = Brush { name: "s".into(), kind: BrushKind::Scatter(sc) };+        let heights: Vec<f64> =+            stroke_pieces(&Document::new(1.0, 1.0), &s, &horizontal, &stroke(1.0)).iter().map(|n| n.geometric_bounds().unwrap().height()).collect();+        assert!(!heights.is_empty() && heights.iter().all(|h| near(*h, 7.0, 1e-6)), "{mode:?}: {heights:?}");+    }+}++/// The scatter brush of `b`.+fn scatter(b: &Brush) -> Scatter {+    let BrushKind::Scatter(sc) = &b.kind else { panic!("not a scatter brush") };+    sc.clone()+}++/// #764: Scatter values on Tilt go from the first value with the pen lying flat to the second+/// upright; on Bearing, from the first at −180° to the second at 180°.+#[test]+fn scatter_values_follow_tilt_and_bearing() {+    let bp = line(0.0, 0.0, 400.0, 0.0);+    let boxes = |b: &Brush, st: &StrokeLayer| -> Vec<Rect> {+        stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap()).collect()+    };+    // Size on Tilt, 50 % to 300 %: upright at the start (12 pt tall), flat at the end (2 pt).+    let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };+    let h: Vec<f64> = boxes(&sized_scatter(50.0, 300.0, Variation::Tilt), &upright_to_flat).iter().map(Rect::height).collect();+    assert!(near(h[0], 12.0, 0.2) && h[h.len() - 1] < 3.0, "{h:?}");+    assert!(h.windows(2).all(|w| w[1] <= w[0] + 1e-9), "shrinks as the pen tips over: {h:?}");+    // Rotation on Bearing, −180° to 180°: the copies turn with the bearing, a quarter turn+    // standing the 10 × 4 pt art on end.+    let sc = Scatter {+        rotation: (-180.0, 180.0),+        modes: Some([Variation::Fixed, Variation::Fixed, Variation::Fixed, Variation::Bearing]),+        ..scatter(&scatter_brush(100.0))+    };+    let turning = Brush { name: "r".into(), kind: BrushKind::Scatter(sc) };+    for (bearing, (w, h)) in [(0.0, (10.0, 4.0)), (90.0, (4.0, 10.0)), (-90.0, (4.0, 10.0))] {+        let b = boxes(&turning, &posed(Some(0.5), Some(bearing), None));+        assert!(!b.is_empty() && b.iter().all(|r| near(r.width(), w, 1e-6) && near(r.height(), h, 1e-6)), "{bearing}: {b:?}");+    } }  /// Brushes saved before the variation modes keep their look: a variation means Random, none@@ -527,7 +648,7 @@ fn brushes_without_modes_vary_randomly_as_before() {         stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))     );     // Modes are saved by name.-    assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));+    assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure", "tilt", "bearing", "rotation"]));     assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v))); } diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs--- a/crates/brush/src/warp.rs+++ b/crates/brush/src/warp.rs@@ -6,11 +6,11 @@  use std::sync::Arc; -use vectorcraft_doc::{Node, NodeKind, PressureProfile};+use vectorcraft_doc::{Node, NodeKind, PressureProfile, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};  use crate::track::{Rng, Track, seed_of, tracks};-use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};+use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, PenAt, Scatter, Variation, tolerance};  /// Longest mapped segment (points in document space). const MAX_SEG: f64 = 1.5;@@ -287,18 +287,19 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre }  /// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a-/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value-/// draws, so a copy's random values stay the same whatever the other modes.-fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {+/// value between them, and a mode that follows the pen the first where `pen` reads 0 for it and+/// the second where it reads 1. Every value draws, so a copy's random values stay the same+/// whatever the other modes.+fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pen: PenAt) -> f64 {     let r = rng.range(a, b);-    match mode {-        Variation::Fixed => a,-        Variation::Random => r,-        Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),+    match pen.control(mode) {+        Some(c) => a + (b - a) * c,+        None if mode == Variation::Random => r,+        None => a,     } } -pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {+pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {     let Some(b) = sc.art.geometric_bounds() else { return vec![] };     let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));     let mut rng = Rng::new(seed_of(bp, name));@@ -309,8 +310,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<             break;         }         let l = t.len();-        // The pen pressure at arc length `at` (half way without a recorded one).-        let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));+        // What the pen recorded at arc length `at` (the values themselves without a recording).+        let pen_at = |at: f64| PenAt::of(stroke, if l > 1e-9 { at / l } else { 0.0 });         let mut s: Option<f64> = None;         let mut guard = 0;         loop {@@ -318,8 +319,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<             if guard > 20000 {                 break;             }-            // Size and spacing place the next copy, so they take the pressure at the last one.-            let p = pressure_at(s.unwrap_or(0.0));+            // Size and spacing place the next copy, so they take the pen at the last one.+            let p = pen_at(s.unwrap_or(0.0));             let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;             let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;             let step = aw * size * spacing;@@ -331,7 +332,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<                 break;             }             s = Some(pos);-            let p = pressure_at(pos);+            let p = pen_at(pos);             let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;             let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();             let (p, tg) = t.frame(pos);diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs--- a/crates/doc/src/appearance.rs+++ b/crates/doc/src/appearance.rs@@ -10,10 +10,12 @@ mod stroke_style;  pub(crate) use layers::HEAD_REACH; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};-pub use profile::{PressureProfile, ProfilePreset, SavedProfile, WidthProfile};+pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};  /// One entry of the appearance stack.+// A stroke takes 328 bytes and a fill 104; an appearance holds a few of each.+#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum AppearanceItem {@@ -126,11 +128,15 @@ impl Appearance {     pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {         self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })     }-    /// This appearance for other art: the pen pressure its strokes were drawn with stays with them.+    /// This appearance for other art: the pen pressure, tilt, bearing and rotation its strokes+    /// were drawn with stay with them.     pub fn without_pressure(mut self) -> Self {         for it in &mut self.items {             if let AppearanceItem::Stroke(s) = it {                 s.pressure = None;+                s.tilt = None;+                s.bearing = None;+                s.rotation = None;             }         }         selfdiff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs--- a/crates/doc/src/appearance/layers.rs+++ b/crates/doc/src/appearance/layers.rs@@ -1,4 +1,4 @@-use super::profile::{PressureProfile, WidthProfile};+use super::profile::{AngleProfile, PressureProfile, WidthProfile}; use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; use super::*; @@ -93,6 +93,21 @@ pub struct StrokeLayer {     /// art leave it behind ([`Appearance::without_pressure`]).     #[serde(default, skip_serializing_if = "Option::is_none")]     pub pressure: Option<PressureProfile>,+    /// How far the pen leaned from upright along the stroke (Paintbrush with a tablet that reads+    /// tilt): 0 upright to 1 lying flat (90°). Brushes with Tilt variation vary with it. Like the+    /// pressure, it stays with this path.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub tilt: Option<PressureProfile>,+    /// Which way the pen leaned along the stroke, in degrees counter-clockwise on the page from the+    /// x axis. Brushes with Bearing variation vary with it. Like the pressure, it stays with this+    /// path.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub bearing: Option<AngleProfile>,+    /// How far the pen's barrel was turned along the stroke, in degrees counter-clockwise (a pen+    /// that reads barrel rotation). Brushes with Rotation variation vary with it. Like the+    /// pressure, it stays with this path.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub rotation: Option<AngleProfile>, }  /// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant@@ -167,6 +182,9 @@ impl StrokeLayer {             overprint: false,             gradient_mode: StrokeGradientMode::Within,             pressure: None,+            tilt: None,+            bearing: None,+            rotation: None,         }     }     /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at leastdiff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs--- a/crates/doc/src/appearance/profile.rs+++ b/crates/doc/src/appearance/profile.rs@@ -1,3 +1,5 @@+use std::borrow::Cow;+ use super::*;  /// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),@@ -92,7 +94,8 @@ impl WidthProfile {  /// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1 /// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s-/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.+/// are. The pen's tilt is kept the same way ([`StrokeLayer::tilt`]). Calligraphic and Scatter+/// brushes with Pressure or Tilt variation read them; everything else ignores them. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct PressureProfile {     pub points: Vec<(f64, f64)>,@@ -109,18 +112,7 @@ impl PressureProfile {      /// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.     pub fn at(&self, t: f64) -> f64 {-        let p = &self.points;-        let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };-        // (Positions go forward; a profile read from a file is searched as if they do, in log time.)-        let v = if t <= first.0 {-            first.1-        } else {-            let i = p.partition_point(|q| q.0 < t);-            match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {-                (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),-                _ => last.1,-            }-        };+        let v = interpolated(&self.points, t).unwrap_or(Self::MID);         if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }     } @@ -130,44 +122,21 @@ impl PressureProfile {     pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {         let pts: Vec<(f64, f64)> =             samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();-        if pts.is_empty() {-            return None;-        }-        // Simplifying is quadratic at worst: a huge stroke is thinned evenly first.-        let mut pts = thin(pts, 16 * Self::MAX_POINTS);-        // Positions only ever go forward.-        let mut prev = 0.0;-        for p in &mut pts {-            p.0 = p.0.max(prev);-            prev = p.0;-        }-        let keep = simplify_1d(&pts, Self::TOLERANCE);-        let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);-        let round = |v: f64| (v * 1e4).round() / 1e4;-        Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })+        simplified(pts, Self::TOLERANCE).map(|points| Self { points })     }      /// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its     /// end by a stroke `added_len` long (`added`). None when neither has pressure.     pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {-        if old.is_none() && added.is_none() {-            return None;-        }-        let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));-        let total = old_len + added_len;-        if !total.is_finite() || total <= 1e-9 {-            return old.or(added).cloned();+        match extended_points(old.map(|p| p.points.as_slice()), old_len, added.map(|p| p.points.as_slice()), added_len, Self::MID)? {+            Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),+            Cow::Owned(points) => Self::from_samples(points),         }-        let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };-        let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));-        let o = old_len / total;-        let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));-        Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))     }      /// The pressure of the path reversed.     pub fn reversed(&self) -> Self {-        Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }+        Self { points: reversed_points(&self.points) }     }      /// A light–heavy–light stroke, for previews of pressure-sensitive brushes.@@ -176,6 +145,142 @@ impl PressureProfile {     } } +/// A pen angle recorded along a stroke (the Paintbrush with a tablet that reads it): (position 0..1+/// along the path, degrees) samples in order, positions measured as a [`PressureProfile`]'s are.+/// Each sample is joined to the one before it the shorter way round, so the degrees can run past+/// ±180° and the angle read between two samples turns through the smaller angle between them.+/// Calligraphic and Scatter brushes with Bearing or Rotation variation read it.+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+pub struct AngleProfile {+    pub points: Vec<(f64, f64)>,+}++impl AngleProfile {+    /// How far (in degrees) simplifying may move the recorded curve.+    const TOLERANCE: f64 = 1.0;++    /// The angle at `t` (0..1), linear between samples, in degrees above −180 up to 180; 0+    /// without any samples.+    pub fn at(&self, t: f64) -> f64 {+        let v = interpolated(&self.points, t).unwrap_or(0.0);+        if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 }+    }++    /// A profile from (position, degrees) samples in order along the stroke: non-finite ones are+    /// dropped, each sample is joined to the one before it the shorter way round, and the curve is+    /// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.+    pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {+        let mut prev: Option<f64> = None;+        let pts: Vec<(f64, f64)> = samples+            .into_iter()+            .filter(|(t, a)| t.is_finite() && a.is_finite())+            .map(|(t, a)| {+                let a = prev.map_or(vectorcraft_geom::normalize_deg(a), |p| p + vectorcraft_geom::normalize_deg(a - p));+                prev = Some(a);+                (t.clamp(0.0, 1.0), a)+            })+            .collect();+        simplified(pts, Self::TOLERANCE).map(|points| Self { points })+    }++    /// The angle of a path `old_len` long (`old`; None: drawn without it, 0°) continued at its end+    /// by a stroke `added_len` long (`added`). None when neither has one. Each part keeps the turns+    /// it recorded, more than half a turn between two samples included; the added part is moved by+    /// whole turns so that it starts the shorter way round from where the old part ends.+    pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {+        let end = old.map_or(Some(0.0), |o| o.points.last().map(|p| p.1));+        let added = added.map(|a| {+            let shift = match (end, a.points.first()) {+                (Some(end), Some(&(_, start))) => end + vectorcraft_geom::normalize_deg(start - end) - start,+                _ => 0.0,+            };+            Self { points: a.points.iter().map(|&(t, v)| (t, v + shift)).collect() }+        });+        match extended_points(old.map(|p| p.points.as_slice()), old_len, added.as_ref().map(|p| p.points.as_slice()), added_len, 0.0)? {+            Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),+            Cow::Owned(points) => {+                let pts = points.into_iter().filter(|(t, a)| t.is_finite() && a.is_finite()).map(|(t, a)| (t.clamp(0.0, 1.0), a)).collect();+                simplified(pts, Self::TOLERANCE).map(|points| Self { points })+            }+        }+    }++    /// The angle along the path reversed (the angles stay as they were on the page).+    pub fn reversed(&self) -> Self {+        Self { points: reversed_points(&self.points) }+    }++    /// One turn through every angle along the stroke, for previews of brushes that vary with one.+    pub fn sample() -> Self {+        Self { points: vec![(0.0, -179.0), (1.0, 179.0)] }+    }+}++/// The value of samples `p` (position, value) at `t`, linear between them; `None` without any.+fn interpolated(p: &[(f64, f64)], t: f64) -> Option<f64> {+    let (first, last) = (p.first()?, p.last()?);+    if t <= first.0 {+        return Some(first.1);+    }+    // (Positions go forward; a profile read from a file is searched as if they do, in log time.)+    let i = p.partition_point(|q| q.0 < t);+    Some(match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {+        (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),+        _ => last.1,+    })+}++/// Samples (position, value) with positions in 0..1 and finite values, simplified to within `tol`+/// of the value and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.+fn simplified(pts: Vec<(f64, f64)>, tol: f64) -> Option<Vec<(f64, f64)>> {+    if pts.is_empty() {+        return None;+    }+    // Simplifying is quadratic at worst: a huge stroke is thinned evenly first.+    let mut pts = thin(pts, 16 * PressureProfile::MAX_POINTS);+    // Positions only ever go forward.+    let mut prev = 0.0;+    for p in &mut pts {+        p.0 = p.0.max(prev);+        prev = p.0;+    }+    let keep = simplify_1d(&pts, tol);+    let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), PressureProfile::MAX_POINTS);+    let round = |v: f64| (v * 1e4).round() / 1e4;+    Some(pts.into_iter().map(|(t, p)| (round(t), round(p))).collect())+}++/// The samples of a path `old_len` long (`old`) continued at its end by a stroke `added_len` long+/// (`added`), in order along the whole path, a side without samples (`None`) flat at `none`.+/// `None` when neither side has samples; the side that has them, as they are, when the two+/// lengths add up to no length.+fn extended_points<'a>(+    old: Option<&'a [(f64, f64)]>,+    old_len: f64,+    added: Option<&'a [(f64, f64)]>,+    added_len: f64,+    none: f64,+) -> Option<Cow<'a, [(f64, f64)]>> {+    if old.is_none() && added.is_none() {+        return None;+    }+    let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));+    let total = old_len + added_len;+    if !total.is_finite() || total <= 1e-9 {+        return old.or(added).map(Cow::Borrowed);+    }+    let flat = [(0.0, none), (1.0, none)];+    let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));+    let o = old_len / total;+    let old_pts = old.iter().map(|&(t, p)| (t * o, p));+    Some(Cow::Owned(old_pts.chain(added.iter().map(|&(u, p)| (o + u * (1.0 - o), p))).collect()))+}++/// Samples (position, value) of a path, for the path reversed.+fn reversed_points(points: &[(f64, f64)]) -> Vec<(f64, f64)> {+    points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect()+}+ /// At most `max` of `pts`, evenly spread (the ends kept). fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {     let n = pts.len();diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs--- a/crates/doc/src/appearance/tests.rs+++ b/crates/doc/src/appearance/tests.rs@@ -125,11 +125,50 @@ fn pressure_profile_extends_and_reverses() {     assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]); } +/// #764: a pen's bearing or barrel rotation is kept the shorter way round between samples, so+/// 170° then −170° turns through 180°, not through 0°.+#[test]+fn angle_profiles_join_samples_the_shorter_way_round() {+    let across = AngleProfile::from_samples([(0.0, 170.0), (1.0, -170.0)]).unwrap();+    assert_eq!(across.points, vec![(0.0, 170.0), (1.0, 190.0)]);+    assert_eq!(across.at(0.5), 180.0);+    assert!((across.at(0.75) + 175.0).abs() < 1e-9, "{}", across.at(0.75));+    // Turns past a whole turn keep counting, and read back within −180..180°.+    let spin = AngleProfile::from_samples((0..=40).map(|i| (f64::from(i) / 40.0, f64::from(i * 30) - 180.0))).unwrap();+    assert!((spin.points.last().unwrap().1 - 1380.0).abs() < 1e-9, "{spin:?}");+    assert!((spin.at(1.0) + 60.0).abs() < 1e-9 && spin.points.len() == 2, "{spin:?}");+    // Junk is dropped; nothing at all is no profile, and no profile reads 0°.+    let junk = AngleProfile::from_samples([(f64::NAN, 5.0), (0.5, f64::INFINITY), (0.2, 400.0)]).unwrap();+    assert_eq!(junk.points, vec![(0.2, 40.0)]);+    assert!(AngleProfile::from_samples([(0.5, f64::NAN)]).is_none());+    assert_eq!(AngleProfile::default().at(0.3), 0.0);+    let noisy = AngleProfile::from_samples((0..1_000_000).map(|i| (f64::from(i) / 1e6, f64::from(i % 2) * 90.0))).unwrap();+    assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);+    // Continued: the old part, then the new one; a side without an angle is 0°. Reversed, the+    // angles stay what they were.+    let old = AngleProfile { points: vec![(0.0, 90.0), (1.0, 90.0)] };+    let joined = AngleProfile::extended(Some(&old), 30.0, None, 10.0).unwrap();+    assert!((joined.at(0.5) - 90.0).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");+    assert!(AngleProfile::extended(None, 10.0, None, 10.0).is_none());+    assert_eq!(AngleProfile::extended(Some(&old), 0.0, None, 0.0), Some(old.clone()));+    assert_eq!(across.reversed().points, vec![(0.0, 190.0), (1.0, 170.0)]);+    // A part that turned more than half a turn between two of its samples keeps that turn when+    // the path is continued, and so does the new part.+    let quarter_turns = AngleProfile::from_samples([(0.0, 0.0), (1.0 / 3.0, 90.0), (2.0 / 3.0, 180.0), (1.0, 270.0)]).unwrap();+    assert_eq!(quarter_turns.points, vec![(0.0, 0.0), (1.0, 270.0)]);+    let added = AngleProfile { points: vec![(0.0, -80.0), (1.0, 280.0)] };+    let joined = AngleProfile::extended(Some(&quarter_turns), 10.0, Some(&added), 10.0).unwrap();+    assert!((joined.at(0.25) - 135.0).abs() < 1e-9, "{joined:?}");+    assert!((joined.at(0.75) - 100.0).abs() < 1e-9, "{joined:?}");+}+ /// Strokes saved before pen pressure load as they were, and are written without it. #[test] fn strokes_without_pressure_round_trip_unchanged() {     let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();-    assert!(json.get("pressure").is_none());+    for key in ["pressure", "tilt", "bearing", "rotation"] {+        assert!(json.get(key).is_none(), "{key}");+    }     let st: StrokeLayer = serde_json::from_value(json).unwrap();     assert_eq!(st.pressure, None);     let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };@@ -138,6 +177,19 @@ fn strokes_without_pressure_round_trip_unchanged() {     let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);     ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());     assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);+    // The pen's tilt, bearing and rotation load, save and stay behind the same way.+    let posed = StrokeLayer {+        tilt: Some(PressureProfile::sample()),+        bearing: Some(AngleProfile::sample()),+        rotation: Some(AngleProfile { points: vec![(0.0, -45.0)] }),+        ..back+    };+    let again: StrokeLayer = serde_json::from_value(serde_json::to_value(&posed).unwrap()).unwrap();+    assert_eq!(again, posed);+    let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);+    *ap.stroke_mut().unwrap() = posed;+    let st = ap.without_pressure().stroke().cloned().unwrap();+    assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None)); }  #[test]diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs--- a/crates/doc/src/lib.rs+++ b/crates/doc/src/lib.rs@@ -72,8 +72,8 @@ pub(crate) mod skip;  pub use appearance::StrokeGradientMode; pub use appearance::{-    Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,-    StrokeAlign, StrokeLayer, WidthProfile,+    AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset,+    SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use corners::LiveCorners;diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs--- a/crates/engine/src/cmd/brushsym.rs+++ b/crates/engine/src/cmd/brushsym.rs@@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Brush Options…",             [],             None,-            "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",+            "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",             has_doc,             brush_options         ),diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs--- a/crates/engine/src/cmd/draw2.rs+++ b/crates/engine/src/cmd/draw2.rs@@ -7,7 +7,7 @@ use std::sync::Arc; use kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint};-use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};+use vectorcraft_doc::{AngleProfile, Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection, StrokeLayer}; use vectorcraft_geom::hit::fill_contains; use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use vectorcraft_pathops as po;diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs--- a/crates/engine/src/cmd/draw2/commands.rs+++ b/crates/engine/src/cmd/draw2/commands.rs@@ -11,7 +11,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Pencil",             [],             None,-            "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",+            "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",             has_doc,             freehand         ),diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs--- a/crates/engine/src/cmd/draw2/common.rs+++ b/crates/engine/src/cmd/draw2/common.rs@@ -4,9 +4,26 @@ use super::*;  /// `[[x,y]…]` or `[{x,y}…]`. pub(super) fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {-    Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())+    Ok(samples_param(p, key, cmd)?.into_iter().map(|s| s.pt).collect()) } +/// One point of a stroke, with what a pen read there.+#[derive(Clone, Copy, Debug, PartialEq)]+pub(super) struct Sample {+    pub(super) pt: Point,+    /// Pressure, 0..1.+    pressure: Option<f64>,+    /// Tilt `[x, y]` in degrees, each −90..90, in document axes (x positive leaning right, y+    /// positive leaning down the page).+    tilt: Option<[f64; 2]>,+    /// Barrel rotation, degrees clockwise.+    rotation: Option<f64>,+}++/// The keys of a point's pen values, in the order a point given as an array holds them after its+/// x and y.+const PEN_KEYS: [&str; 4] = ["pressure", "tiltX", "tiltY", "rotation"];+ /// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite /// number. pub(super) fn point_entry(v: &Value, cmd: &str) -> Result<Point> {@@ -18,22 +35,31 @@ pub(super) fn point_entry(v: &Value, cmd: &str) -> Result<Point> {     xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers")) } -/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or-/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a-/// number nor null, is an error.-pub(super) fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {+/// [`points_param`] with what a pen read at each point, as far as it is given:+/// `[[x,y,pressure,tiltX,tiltY,rotation]…]` or `[{x,y,pressure,tiltX,tiltY,rotation}…]`. Pressure+/// is 0..1; tilt in degrees, each −90..90 (one of the two without the other is 0); rotation in+/// degrees clockwise. A point whose x or y is not a number, or whose pressure, tilt or rotation is+/// neither a number nor null, is an error.+pub(super) fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Sample>> {     let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;-    let pts: Vec<(Point, Option<f64>)> = a+    let pts: Vec<Sample> = a         .iter()         .map(|v| {-            let pressure = match v {-                Value::Array(xy) => xy.get(2),-                _ => v.get("pressure"),-            };-            match pressure {-                None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))),-                Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))),+            let mut pen = [None; 4];+            for (i, (key, slot)) in PEN_KEYS.iter().zip(&mut pen).enumerate() {+                let given = match v {+                    Value::Array(xy) => xy.get(i + 2),+                    _ => v.get(*key),+                };+                *slot = match given {+                    None | Some(Value::Null) => None,+                    Some(n @ Value::Number(_)) => n.as_f64(),+                    Some(v) => return Err(bad(cmd, format!("`{key}` must be a number, not {v}"))),+                };             }+            let [pressure, tx, ty, rotation] = pen;+            let tilt = (tx.is_some() || ty.is_some()).then(|| [tx, ty].map(|t| t.unwrap_or(0.0).clamp(-90.0, 90.0)));+            Ok(Sample { pt: point_entry(v, cmd)?, pressure, tilt, rotation })         })         .collect::<Result<_>>()?;     if pts.is_empty() {@@ -42,23 +68,117 @@ pub(super) fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Poin     Ok(pts) } -/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of-/// its samples has one; samples without one count as full pressure, as a pen event without one.-pub(super) fn stroke_pressure(samples: &[(Point, Option<f64>)]) -> Option<PressureProfile> {-    if samples.iter().all(|(_, p)| p.is_none()) {-        return None;-    }+/// Where each sample of a freehand stroke is along its polyline, as a fraction of its length.+fn along(samples: &[Sample]) -> Vec<f64> {     let mut at = 0.0;     let mut prev: Option<Point> = None;-    let along: Vec<(f64, f64)> = samples+    let dist: Vec<f64> = samples         .iter()-        .map(|(pt, p)| {-            at += prev.map_or(0.0, |q| q.distance(*pt));-            prev = Some(*pt);-            (at, p.unwrap_or(1.0))+        .map(|s| {+            at += prev.map_or(0.0, |q| q.distance(s.pt));+            prev = Some(s.pt);+            at         })         .collect();-    PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p)))+    dist.into_iter().map(|s| if at > 1e-9 { s / at } else { 0.0 }).collect()+}++/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of+/// its samples has one; samples without one count as full pressure, as a pen event without one.+fn stroke_pressure(samples: &[Sample]) -> Option<PressureProfile> {+    if samples.iter().all(|s| s.pressure.is_none()) {+        return None;+    }+    PressureProfile::from_samples(along(samples).into_iter().zip(samples).map(|(t, s)| (t, s.pressure.unwrap_or(1.0))))+}++/// A pen that leans less than this many degrees from upright leans no way in particular: its+/// bearing is the one before it.+const UPRIGHT: f64 = 1.0;++/// How far the pen leaned from upright (0 upright to 1 flat), which way (degrees+/// counter-clockwise on the page from the x axis) and how far its barrel turned (degrees+/// counter-clockwise) along a freehand stroke, each when any sample has it. A sample without the+/// tilt or the rotation that others have counts as half way over (45°) or unturned, which draws a+/// brush's own value. The bearing of a pen within 1° of upright is the bearing before it (the+/// first one the stroke has, at its start).+fn stroke_pose(samples: &[Sample]) -> (Option<PressureProfile>, Option<AngleProfile>, Option<AngleProfile>) {+    let at = along(samples);+    // The tilt's lean as a direction on the page, and the angle from upright (as Pointer Events'+    // tiltX and tiltY convert to an altitude and an azimuth).+    let lean = |t: [f64; 2]| t.map(|d| d.to_radians().tan());+    let from_upright = |t: [f64; 2]| {+        let [x, y] = lean(t);+        x.hypot(y).atan().to_degrees()+    };+    let tilt = samples.iter().any(|s| s.tilt.is_some()).then(|| {+        let tilts = at.iter().zip(samples).map(|(t, s)| (*t, s.tilt.map_or(PressureProfile::MID, |q| from_upright(q) / 90.0)));+        PressureProfile::from_samples(tilts)+    });+    let bearing = samples.iter().any(|s| s.tilt.is_some()).then(|| {+        let leans: Vec<Option<f64>> =+            samples.iter().map(|s| s.tilt.filter(|q| from_upright(*q) >= UPRIGHT).map(lean).map(|[x, y]| (-y).atan2(x).to_degrees())).collect();+        let mut last = leans.iter().flatten().next().copied().unwrap_or(0.0);+        AngleProfile::from_samples(at.iter().zip(leans).map(|(t, b)| {+            last = b.unwrap_or(last);+            (*t, last)+        }))+    });+    let rotation = samples+        .iter()+        .any(|s| s.rotation.is_some())+        .then(|| AngleProfile::from_samples(at.iter().zip(samples).map(|(t, s)| (*t, -s.rotation.unwrap_or(0.0)))));+    (tilt.flatten(), bearing.flatten(), rotation.flatten())+}++/// What a pen recorded along a stroke ([`StrokeLayer`]'s `pressure`, `tilt`, `bearing` and+/// `rotation`).+#[derive(Clone, Debug, Default, PartialEq)]+pub(super) struct PenRecord {+    pressure: Option<PressureProfile>,+    tilt: Option<PressureProfile>,+    bearing: Option<AngleProfile>,+    rotation: Option<AngleProfile>,+}++impl PenRecord {+    /// What a freehand stroke's samples recorded.+    pub(super) fn of_samples(samples: &[Sample]) -> Self {+        let (tilt, bearing, rotation) = stroke_pose(samples);+        Self { pressure: stroke_pressure(samples), tilt, bearing, rotation }+    }+    pub(super) fn of(st: &StrokeLayer) -> Self {+        Self { pressure: st.pressure.clone(), tilt: st.tilt.clone(), bearing: st.bearing.clone(), rotation: st.rotation.clone() }+    }+    pub(super) fn is_empty(&self) -> bool {+        *self == Self::default()+    }+    /// The record of a path `old_len` long (this one) continued at its end by a stroke+    /// `added_len` long (`added`).+    pub(super) fn extended(&self, old_len: f64, added: &Self, added_len: f64) -> Self {+        Self {+            pressure: PressureProfile::extended(self.pressure.as_ref(), old_len, added.pressure.as_ref(), added_len),+            tilt: PressureProfile::extended(self.tilt.as_ref(), old_len, added.tilt.as_ref(), added_len),+            bearing: AngleProfile::extended(self.bearing.as_ref(), old_len, added.bearing.as_ref(), added_len),+            rotation: AngleProfile::extended(self.rotation.as_ref(), old_len, added.rotation.as_ref(), added_len),+        }+    }+    /// The record along the path reversed.+    pub(super) fn reversed(&self) -> Self {+        Self {+            pressure: self.pressure.as_ref().map(PressureProfile::reversed),+            tilt: self.tilt.as_ref().map(PressureProfile::reversed),+            bearing: self.bearing.as_ref().map(AngleProfile::reversed),+            rotation: self.rotation.as_ref().map(AngleProfile::reversed),+        }+    }+    /// Keep the record on stroke `st`.+    pub(super) fn write(self, st: &mut StrokeLayer) {+        st.pressure = self.pressure;+        st.tilt = self.tilt;+        st.bearing = self.bearing;+        st.rotation = self.rotation;+    } }  /// The length of a subpath.diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs--- a/crates/engine/src/cmd/draw2/freehand.rs+++ b/crates/engine/src/cmd/draw2/freehand.rs@@ -1,5 +1,5 @@ use super::common::{-    brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths,+    PenRecord, brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, subpath_len, target_paths,     within_input_bound, }; use super::*;@@ -139,11 +139,11 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option<f64>, within: f64) - pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {     const C: &str = "path.freehand";     let samples = samples_param(p, "points", C)?;-    if samples.iter().any(|(q, _)| !within_input_bound(*q)) {+    if samples.iter().any(|q| !within_input_bound(q.pt)) {         return Err(bad(C, "x and y must be finite"));     }-    let pressure = stroke_pressure(&samples);-    let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();+    let pen = PenRecord::of_samples(&samples);+    let mut pts: Vec<Point> = samples.into_iter().map(|s| s.pt).collect();     let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0);     // The tolerance the stroke is fitted at; with `fit: false` it is not fitted.     let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol);@@ -236,15 +236,16 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {             sp.anchors.extend_from_slice(rest);             other_len = subpath_len(y);         }-        // The path's pressure (in the direction it is continued in) followed by the stroke's, and-        // by the pressure of the path it connects to.-        let pressure_of = |n: NodeId| s.doc().ok()?.doc.node(n).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());-        let old_pressure = pressure_of(id);-        let other_pressure = connect.as_ref().and_then(|(o, _, _, at_end)| pressure_of(*o).map(|p| if *at_end { p.reversed() } else { p }));-        let joined = (old_pressure.is_some() || pressure.is_some() || other_pressure.is_some()).then(|| {-            let old = old_pressure.map(|p| if at_start { p.reversed() } else { p });-            let drawn = PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), drawn_len - old_len);-            if connect.is_some() { PressureProfile::extended(drawn.as_ref(), drawn_len, other_pressure.as_ref(), other_len) } else { drawn }+        // What the pen recorded along the path (in the direction it is continued in), followed by+        // what it recorded along the stroke, and by what it recorded along the path it connects to.+        let record_of =+            |n: NodeId| s.doc().ok().and_then(|d| d.doc.node(n).and_then(|n| n.appearance.stroke()).map(PenRecord::of)).unwrap_or_default();+        let old = record_of(id);+        let other = connect.as_ref().map(|(o, _, _, at_end)| if *at_end { record_of(*o).reversed() } else { record_of(*o) }).unwrap_or_default();+        let joined = (!old.is_empty() || !pen.is_empty() || !other.is_empty()).then(|| {+            let old = if at_start { old.reversed() } else { old };+            let drawn = old.extended(old_len, &pen, drawn_len - old_len);+            if connect.is_some() { drawn.extended(drawn_len, &other, other_len) } else { drawn }         });         s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {             let reverse = at_start && !sp.closed;@@ -280,7 +281,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {             if let Some(joined) = joined                 && let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut())             {-                st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };+                if reverse { joined.reversed() } else { joined }.write(st);             }             if keep_selected {                 sel.set([id]);@@ -298,8 +299,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {             return Err(bad(C, "redraw within must be a finite distance of 0 or more"));         }         let (pd, _) = path_of(&s.doc()?.doc, id)?;-        // shortcut: a stroke pressure profile is left as it was, measured along the old path; fit-        // it to the new length if Calligraphic pressure strokes get redrawn.+        // shortcut: a stroke's pressure, tilt, bearing and rotation profiles are left as they+        // were, measured along the old path; fit them to the new length if strokes drawn with a+        // pen get redrawn.         if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) {             s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {                 *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;@@ -326,7 +328,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {     let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };     let mut look = s.new_art_look(fill, stroke, s.paint.stroke_width.max(0.1));     if let Some(st) = look.appearance.stroke_mut() {-        st.pressure = pressure;+        pen.write(st);     }     insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected) }diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs--- a/crates/engine/src/cmd/globaledit.rs+++ b/crates/engine/src/cmd/globaledit.rs@@ -30,8 +30,8 @@ //! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending, //!   effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the //!   same rank (the second fill from the bottom to the second fill), and a member without one is-//!   left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure a-//!   stroke was drawn with stays each member's own;+//!   left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure,+//!   tilt, bearing and rotation a stroke was drawn with stay each member's own; //! - a fill or stroke added or removed: a member whose fills and strokes lie in the model's //!   former order gains the same item at the same place (or loses the one at that place). A //!   member with another order, and any other change to the list (items moved, or several added@@ -486,7 +486,8 @@ fn give_appearance(a: &Appearance, b: &Appearance, m: &Appearance, n: &mut Share }  /// Give the member's fill or stroke `n` the settings of the model's that changed from `a` to `b`,-/// with the values of `m` (`b` placed on the member). A stroke's pen pressure stays its own.+/// with the values of `m` (`b` placed on the member). A stroke's pen pressure, tilt, bearing and+/// rotation stay its own. fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut AppearanceItem) {     use AppearanceItem::{Fill, Stroke};     match (a, b, m, n) {@@ -516,6 +517,9 @@ fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut                 overprint,                 gradient_mode,                 pressure: _,+                tilt: _,+                bearing: _,+                rotation: _,             } = m;             give_fields!(a, b, n: paint, width, cap, join, miter_limit, align, dash, start_arrow, end_arrow, arrow_scale, profile, brush, opacity, blend, visible, effects, arrow_align, overprint, gradient_mode);         }diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs--- a/crates/engine/src/cmd/select.rs+++ b/crates/engine/src/cmd/select.rs@@ -716,6 +716,8 @@ fn same_graphic_style(s: &mut Session, _: &Value) -> Result<Value> { }  /// An appearance attribute Select > Same > Appearance Attribute matches.+// An appearance item takes up to 328 bytes and an effect 64; one search holds a single attribute.+#[allow(clippy::large_enum_variant)] enum Attribute {     Item(vectorcraft_doc::AppearanceItem),     Effect(vectorcraft_doc::Effect),diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs--- a/crates/engine/src/tests/brushsym.rs+++ b/crates/engine/src/tests/brushsym.rs@@ -1,9 +1,9 @@ //! Brushes and symbols: commands, tools, rendering and persistence.  use serde_json::{Value, json};-use vectorcraft_doc::{Document, Node, NodeKind};+use vectorcraft_doc::{AngleProfile, Document, Node, NodeKind, PressureProfile}; use vectorcraft_geom::Rect;-use vectorcraft_tools::{Mods, PointerEvent, PointerKind};+use vectorcraft_tools::{Mods, PenPose, PointerEvent, PointerKind};  use crate::*; @@ -641,20 +641,120 @@ fn continuing_a_stroke_joins_its_pressure() {     assert_eq!(p.at(1.0), vectorcraft_doc::PressureProfile::MID, "the old part had no pressure"); } +/// The pen's tilt, bearing and barrel rotation recorded on the stroke `id`.+fn pose_of(s: &Session, id: NodeId) -> (Option<PressureProfile>, Option<AngleProfile>, Option<AngleProfile>) {+    let st = node(s, id).appearance.stroke().cloned().unwrap();+    (st.tilt, st.bearing, st.rotation)+}++/// #764: `path.freehand` points carry the pen's tilt and barrel rotation; the stroke keeps how far+/// the pen leaned (0 upright to 1 flat), which way (counter-clockwise on the page) and how far its+/// barrel turned (counter-clockwise), and a continued stroke joins them.+#[test]+fn freehand_points_record_the_pens_tilt_bearing_and_rotation() {+    let mut s = session();+    let draw = |s: &mut Session, points: Value| {+        NodeId(s.execute("path.freehand", &json!({"points": points, "style": "brush"})).unwrap()["id"].as_u64().unwrap())+    };+    // Leaning 30° to the right, the barrel turned 90° clockwise.+    let id = draw(&mut s, json!([[0, 50, 0.5, 30, 0, 90], [100, 50, 0.5, 30, 0, 90]]));+    let (tilt, bearing, rotation) = pose_of(&s, id);+    assert!((tilt.unwrap().at(0.5) - 1.0 / 3.0).abs() < 1e-3);+    assert!(bearing.unwrap().at(0.5).abs() < 1e-6);+    assert!((rotation.unwrap().at(0.5) + 90.0).abs() < 1e-6, "counter-clockwise");+    // Leaning down the page (towards the user) is −90°; no rotation axis, no rotation.+    let id = draw(&mut s, json!([[0, 150, 0.5, 0, 30, null], {"x": 100, "y": 150, "pressure": 0.5, "tiltY": 30}]));+    let (_, bearing, rotation) = pose_of(&s, id);+    assert!((bearing.unwrap().at(0.5) + 90.0).abs() < 1e-6);+    assert_eq!(rotation, None);+    // Upright, the pen leans no way: the bearing it leaned before.+    let id = draw(&mut s, json!([[0, 250, 0.5, 0, -40], [50, 250, 0.5, 0.2, 0.2], [100, 250, 0.5, 0, 0]]));+    let (tilt, bearing, _) = pose_of(&s, id);+    let bearing = bearing.unwrap();+    assert!((bearing.at(1.0) - 90.0).abs() < 1e-6 && tilt.unwrap().at(1.0) < 1e-6, "{bearing:?}");+    // Pressure alone, or nothing: no pen pose.+    let id = draw(&mut s, json!([[0, 350, 0.5], [100, 350, 0.5]]));+    assert_eq!(pose_of(&s, id), (None, None, None));+    // Continued at its end: the old part keeps its rotation, the new part has none (unturned).+    let id = draw(&mut s, json!([[0, 450, 0.5, 30, 0, 90], [100, 450, 0.5, 30, 0, 90]]));+    s.execute("path.freehand", &json!({"points": [[100, 450], [200, 450]], "style": "brush", "extend": {"id": id.0, "end": "end"}})).unwrap();+    let (tilt, _, rotation) = pose_of(&s, id);+    let rotation = rotation.unwrap();+    assert!((rotation.at(0.25) + 90.0).abs() < 1e-6 && rotation.at(0.9).abs() < 1e-6, "{rotation:?}");+    assert_eq!(tilt.unwrap().at(0.9), vectorcraft_doc::PressureProfile::MID, "half way over without one");+}++/// #764: a stroke that connects two paths joins the pen's tilt, bearing and rotation of both, the+/// connected path's after the stroke's.+#[test]+fn connecting_two_strokes_joins_their_pens_rotation() {+    let mut s = session();+    let draw = |s: &mut Session, points: Value| {+        NodeId(s.execute("path.freehand", &json!({"points": points, "style": "brush"})).unwrap()["id"].as_u64().unwrap())+    };+    let a = draw(&mut s, json!([[0, 50, 0.5, 30, 0, 90], [100, 50, 0.5, 30, 0, 90]]));+    let b = draw(&mut s, json!([[200, 50, 0.5, 30, 0, 45], [300, 50, 0.5, 30, 0, 45]]));+    let connect =+        json!({"points": [[100, 50], [200, 50]], "style": "brush", "extend": {"id": a.0, "end": "end"}, "connect": {"id": b.0, "end": "start"}});+    s.execute("path.freehand", &connect).unwrap();+    assert!(s.doc().unwrap().doc.node(b).is_none(), "joined on");+    let (_, _, rotation) = pose_of(&s, a);+    let rotation = rotation.unwrap();+    assert!((rotation.at(0.1) + 90.0).abs() < 1e-6, "{rotation:?}");+    assert!(rotation.at(0.5).abs() < 1e-6, "the stroke is unturned: {rotation:?}");+    assert!((rotation.at(0.9) + 45.0).abs() < 1e-6, "the connected path's: {rotation:?}");+}++/// #764: the Paintbrush records a pen's tilt, bearing and rotation on the stroke, a brush with+/// Roundness on Tilt draws it, and it lasts through saving; a mouse stroke records none.+#[test]+fn paintbrush_records_the_pens_tilt_on_the_stroke() {+    let mut s = session();+    let params = json!({"angle": 0, "roundness": 50, "size": 10, "variation": [0, 50, 0], "modes": ["fixed", "tilt", "fixed"]});+    s.execute("brush.options", &json!({"name": "3 pt. Round", "params": params})).unwrap();+    s.execute("brush.setCurrent", &json!({"name": "3 pt. Round"})).unwrap();+    let v = ViewInfo::default();+    s.select_tool("paintbrush", v).unwrap();+    let strokes = |s: &mut Session, y: f64, pen: Option<PenPose>| {+        for (k, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 120.0), (PointerKind::Drag, 220.0), (PointerKind::Up, 320.0)] {+            s.pointer(&PointerEvent { pen, ..PointerEvent::new(k, x, y) }, v).unwrap();+        }+        let id = s.doc().unwrap().selection.objects[0];+        select(s, &[]);+        id+    };+    let pen = strokes(&mut s, 60.0, Some(PenPose { tilt: Some([0.0, 0.0]), rotation: Some(30.0) }));+    let (tilt, bearing, rotation) = pose_of(&s, pen);+    assert_eq!(tilt.as_ref().map(|t| t.at(0.5)), Some(0.0), "upright");+    assert!(bearing.is_some() && (rotation.unwrap().at(0.5) + 30.0).abs() < 1e-6);+    let mouse = strokes(&mut s, 200.0, None);+    assert_eq!(pose_of(&s, mouse), (None, None, None), "a mouse reads no tilt");+    // Drawn: upright, the nib is round (10 pt); without a tilt, roundness 50 (5 pt).+    assert!((brushed_bounds(&s, pen).height() - 10.0).abs() < 0.1, "{:?}", brushed_bounds(&s, pen));+    assert!((brushed_bounds(&s, mouse).height() - 5.0).abs() < 0.1, "{:?}", brushed_bounds(&s, mouse));+    // Saved and reopened as it was.+    let doc: Document = (*s.doc().unwrap().doc).clone();+    let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();+    assert_eq!(back.node(pen).unwrap().appearance.stroke(), doc.node(pen).unwrap().appearance.stroke());+}+ /// Pen pressure belongs to its path: new art, graphic styles and Brush Options' edits leave it. #[test] fn pressure_stays_with_its_own_path() {     let mut s = session();     s.execute("appearance.setNewArtBasic", &json!({"on": false})).unwrap();-    let r = s.execute("brush.freehand", &json!({"points": [[0, 50, 0.2], [100, 50, 0.9]], "style": "brush", "brush": "3 pt. Round"})).unwrap();+    let points = json!([[0, 50, 0.2, 30, 10, 45], [100, 50, 0.9, 20, 0, 90]]);+    let r = s.execute("brush.freehand", &json!({"points": points, "style": "brush", "brush": "3 pt. Round"})).unwrap();     let id = NodeId(r["id"].as_u64().unwrap());     assert!(pressure_of(&s, id).is_some());+    assert!(matches!(pose_of(&s, id), (Some(_), Some(_), Some(_))));     select(&mut s, &[id]);     s.execute("graphicStyle.new", &json!({"name": "Inked"})).unwrap();     let style = s.doc().unwrap().doc.graphic_style("Inked").unwrap().appearance.clone();-    assert!(style.stroke().is_some_and(|st| st.pressure.is_none()));+    assert!(style.stroke().is_some_and(|st| st.pressure.is_none() && st.tilt.is_none() && st.bearing.is_none() && st.rotation.is_none()));     let other = rect(&mut s, 10.0, 100.0, 50.0, 50.0);     assert_eq!(pressure_of(&s, other), None, "new art takes the look, not the pressure");+    assert_eq!(pose_of(&s, other), (None, None, None), "nor the tilt, bearing or rotation");     pressure_round(&mut s);     assert!(pressure_of(&s, id).is_some(), "editing the brush keeps the strokes' pressure"); }@@ -667,10 +767,18 @@ fn brush_options_set_variation_modes() {     let b = s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap();     assert_eq!(b["modes"], json!(["fixed", "fixed", "pressure"]));     assert_eq!(b["variation"], json!([0.0, 0.0, 3.0]));-    for bad in [json!({"modes": ["fixed", "fixed", "tilt"]}), json!({"modes": "pressure"}), json!({"variation": "x"}), json!({"modes": [1, 2, 3]})] {+    for bad in [json!({"modes": ["fixed", "fixed", "wobble"]}), json!({"modes": "pressure"}), json!({"variation": "x"}), json!({"modes": [1, 2, 3]})]+    {         assert!(s.execute("brush.options", &json!({"name": "3 pt. Round", "params": bad})).is_err(), "{bad}");     }     assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing");+    // #764: Tilt, Bearing and Rotation, on a Calligraphic and a Scatter brush.+    let modes = json!(["tilt", "bearing", "rotation"]);+    s.execute("brush.options", &json!({"name": "3 pt. Round", "params": {"modes": modes}})).unwrap();+    assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap()["modes"], modes);+    let modes = json!(["tilt", "bearing", "rotation", "pressure"]);+    s.execute("brush.options", &json!({"name": "Dots", "params": {"modes": modes}})).unwrap();+    assert_eq!(s.execute("brush.get", &json!({"name": "Dots"})).unwrap()["modes"], modes); }  /// The bounds of the brush art `id`'s strokes draw.@@ -728,7 +836,7 @@ fn scatter_art_and_pattern_brush_options_update_strokes() {     );     assert!(brushed_bounds(&s, id).is_finite());     for (name, bad) in [-        ("Dots", json!({"modes": ["fixed", "tilt", "fixed", "fixed"]})),+        ("Dots", json!({"modes": ["fixed", "wobble", "fixed", "fixed"]})),         ("Dots", json!({"modes": ["fixed"]})),         ("Dots", json!({"colorization": {"method": "sparkle"}})),         ("Arrow", json!({"direction": "diagonal"})),diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs--- a/crates/engine/src/tests/draw2.rs+++ b/crates/engine/src/tests/draw2.rs@@ -1147,8 +1147,9 @@ fn pencil_refuses_a_fidelity_or_within_given_as_text() { }  /// The drawing tools' commands refuse a point whose x or y is not a number, such as "10" or a point-/// written as text, and a pen pressure given as text, instead of leaving the point out or drawing it-/// at full pressure; the document stays as it was.+/// written as text, and a pen pressure, tilt or rotation given as text or another value that is not+/// a number, instead of leaving the point out or drawing it at full pressure; the document stays as+/// it was. #[test] fn drawing_commands_refuse_a_point_or_pressure_given_as_text() {     let mut s = session();@@ -1167,9 +1168,20 @@ fn drawing_commands_refuse_a_point_or_pressure_given_as_text() {             let points: Vec<_> = good.iter().cloned().chain([bad]).collect();             refuses_with(&mut s, cmd, &json!({"points": points}), "`pressure` must be a number, not \"0.5\"");         }+        // The pen's tilt and rotation the same way.+        for (bad, want) in [+            (json!([150, 150, 0.5, "30", 0]), "`tiltX` must be a number, not \"30\""),+            (json!([150, 150, null, 0, true]), "`tiltY` must be a number, not true"),+            (json!({"x": 150, "y": 150, "rotation": [90]}), "`rotation` must be a number, not [90]"),+        ] {+            let points: Vec<_> = good.iter().cloned().chain([bad]).collect();+            refuses_with(&mut s, cmd, &json!({"points": points}), want);+        }     }     let pressed = json!({"points": [[100, 400], [150, 420, null], {"x": 200, "y": 400, "pressure": 0.5}], "keepSelected": false});     s.execute("path.freehand", &pressed).unwrap();+    let posed = json!({"points": [[100, 500, 0.5, 30, null, null], [150, 520, null, null, -20], {"x": 200, "y": 500, "rotation": 45}], "keepSelected": false});+    s.execute("path.freehand", &posed).unwrap(); }  /// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, angle, roundness,diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs--- a/crates/engine/tests/command_sweep.rs+++ b/crates/engine/tests/command_sweep.rs@@ -269,6 +269,20 @@ fn structured_junk() {             json!({"points": [{"x": 0, "y": 0, "pressure": 2}, {"x": 1e308, "y": 0, "pressure": -1}], "extend": {"id": 2, "end": "start"}}),         ),         ("brush.freehand", json!({"points": [[0, 0, 0], [50, 50, 1]], "brush": "3 pt. Round"})),+        (+            "path.freehand",+            json!({"points": [[0, 0, null, 1e308, -1e308, 1e308], [10, 0, 0.5, -500, null, -1e308], [20, 0, 1, 0, 0, 0], [20, 0, 1, 90, 90, 720]], "style": "brush"}),+        ),+        ("path.freehand", json!({"points": [{"x": 0, "y": 0, "tiltX": "a"}, {"x": 10, "y": 0, "rotation": {}}]})),+        (+            "brush.options",+            json!({"name": "3 pt. Round", "params": {"modes": ["bearing", "tilt", "rotation"], "variation": [1e308, 1e308, 1e308], "angle": 1e308}}),+        ),+        (+            "brush.freehand",+            json!({"points": [[0, 0, 0.5, 30, 0, 90], [50, 50, 0.5, -30, 89, 270], [100, 0, 0.5, 0, 0, -1e308]], "brush": "3 pt. Round"}),+        ),+        ("brush.options", json!({"name": "Confetti", "params": {"modes": ["tilt", "bearing", "rotation", "bearing"], "rotation": [-1e308, 1e308]}})),         ("brush.options", json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "random", "nope"]}})),         ("brush.options", json!({"name": "3 pt. Round", "params": {"modes": "pressure", "variation": [1e308, -1e308, 1e308]}})),         (diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs--- a/crates/mcp/src/headless.rs+++ b/crates/mcp/src/headless.rs@@ -192,7 +192,7 @@ impl Headless {             let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?;             let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?;             let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base);-            let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) };+            let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e), pen: PointerEvent::json_pen(e) };             let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;             self.apply_ui_requests(reqs, &mut requests)?;             // With no frames to show the highlight in later, a hover waits for the work the tooldiff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs--- a/crates/mcp/src/tools/definitions.rs+++ b/crates/mcp/src/tools/definitions.rs@@ -209,7 +209,10 @@ pub fn tool_definitions() -> Vec<Value> {                             "x": num("Document x (pt)"),                             "y": num("Document y (pt)"),                             "mods": mods_schema(),-                            "pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"),+                            "pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on, and the pressure the Paintbrush records"),+                            "tiltX": num("Pen tilt in degrees −90..90, positive leaning right (document axes); with tiltY, the tilt the Paintbrush records for Tilt and Bearing brushes. One without the other is 0"),+                            "tiltY": num("Pen tilt in degrees −90..90, positive leaning down the page"),+                            "rotation": num("Pen barrel rotation in degrees clockwise: the rotation the Paintbrush records for Rotation brushes"),                             "holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"),                         }), &["kind", "x", "y"]),                     },diff --git a/crates/tools/src/draw2/anchor/tests.rs b/crates/tools/src/draw2/anchor/tests.rs--- a/crates/tools/src/draw2/anchor/tests.rs+++ b/crates/tools/src/draw2/anchor/tests.rs@@ -261,7 +261,7 @@ fn anchor_point_shift_constrains_a_blend_spine_handle() {     let shift = Mods { shift: true, ..Mods::default() };     let mut tool = AnchorTool::new("anchorPoint");     tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 190.0, 330.0));-    let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, pressure: 1.0 };+    let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, pressure: 1.0, pen: None };     let actions = tool.pointer(&c, &drag);     // The pointer (40, 30) keeps its length of 50; Shift snaps it to 45 degrees.     assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args))diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs--- a/crates/tools/src/draw2/gesture.rs+++ b/crates/tools/src/draw2/gesture.rs@@ -9,7 +9,9 @@ //! key toggles to Smooth Tool off) draws a straight line at any angle. The Pencil's new path starts and ends on //! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points //! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen-//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The+//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and+//! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY,+//! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The //! other tools collect the drag polyline (shown as an overlay) and run //! one command on release. With Shift held, the Blob Brush's points stay on the horizontal or //! vertical line through the press point.@@ -20,7 +22,7 @@ use vectorcraft_geom::Point;  use super::{FEEDBACK, eraser_area, points_json, polyline}; use crate::guides::DrawSnap;-use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};+use crate::{Action, Cursor, Mods, Overlay, PenPose, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};  pub struct GestureTool {     /// How many points the last preview of the stroke had ([`crate::previews_at`]).@@ -29,6 +31,8 @@ pub struct GestureTool {     points: Vec<Point>,     /// The pen pressure of each of `points` (the Paintbrush only).     pressures: Vec<f32>,+    /// The pen's tilt and rotation at each of `points` (the Paintbrush only).+    poses: Vec<Option<PenPose>>,     active: bool,     began: bool,     /// A release where the pointer last dragged to would close the stroke because it ends near@@ -121,6 +125,7 @@ impl GestureTool {             previewed: 0,             points: vec![],             pressures: vec![],+            poses: vec![],             active: false,             began: false,             near_start: false,@@ -165,8 +170,8 @@ impl GestureTool {         self.id != "pencil" || self.live_preview     } -    /// Add a stroke sample.-    fn push(&mut self, p: Point, pressure: f32) {+    /// Add a stroke sample, with the pen's pressure, tilt and rotation of the event `ev` there.+    fn push(&mut self, p: Point, ev: &PointerEvent) {         let raw = self.shift_next.take();         if self.points.is_empty() {             self.shift_raw.clear();@@ -175,7 +180,8 @@ impl GestureTool {         }         self.points.push(p);         if self.id == "paintbrush" {-            self.pressures.push(pressure);+            self.pressures.push(ev.pressure);+            self.poses.push(ev.pen.map(PenPose::sanitized));         }     } @@ -228,15 +234,29 @@ impl GestureTool {     fn clear(&mut self) {         self.points.clear();         self.pressures.clear();+        self.poses.clear();         self.previewed = 0;         self.shift_line = None;     } -    /// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere-    /// along it (a mouse always presses fully).+    /// The stroke's points: `[x, y, pressure, tiltX, tiltY, rotation]` when a pen read its tilt or+    /// rotation anywhere along it (`null` for what a point lacks, and for the pressure of a stroke+    /// pressed fully all along), else `[x, y, pressure]` when a pen pressed less than fully+    /// anywhere (a mouse always presses fully), else `[x, y]`.     fn stroke_json(&self) -> Value {-        if self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0) {-            Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0])).collect())+        let n = self.points.len();+        let pressed = self.pressures.len() == n && self.pressures.iter().any(|p| *p < 1.0);+        let posed = self.poses.len() == n && self.poses.iter().flatten().any(|q| q.tilt.is_some() || q.rotation.is_some());+        let round = |v: f32, k: f64| (f64::from(v) * k).round() / k;+        if posed {+            let points = self.points.iter().zip(&self.pressures).zip(&self.poses).map(|((p, f), q)| {+                let q = q.unwrap_or_default();+                let (tilt, rotation) = (q.tilt.map(|t| t.map(|v| round(v, 100.0))), q.rotation.map(|r| round(r, 100.0)));+                json!([p.x, p.y, pressed.then(|| round(*f, 1000.0)), tilt.map(|t| t[0]), tilt.map(|t| t[1]), rotation])+            });+            Value::Array(points.collect())+        } else if pressed {+            Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, round(*f, 1000.0)])).collect())         } else {             points_json(&self.points)         }@@ -428,7 +448,7 @@ impl Tool for GestureTool {                     p                 };                 self.clear();-                self.push(start, ev.pressure);+                self.push(start, ev);                 vec![]             }             PointerKind::Drag => {@@ -470,7 +490,7 @@ impl Tool for GestureTool {                     self.near_start = self.release_closes_near_start(cx, release_end);                     return vec![];                 }-                self.push(p, ev.pressure);+                self.push(p, ev);                 self.near_start = self.release_closes_near_start(cx, end);                 if !self.draws_path() || self.smoothing {                     return vec![];@@ -503,12 +523,12 @@ impl Tool for GestureTool {                 if self.straight {                     self.points.truncate(1);                     if self.points.first().is_some_and(|s| s.distance(end) >= cx.tol(1.0)) {-                        self.push(end, ev.pressure);+                        self.push(end, ev);                     } else {                         cancel = self.began;                     }                 } else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {-                    self.push(end, ev.pressure);+                    self.push(end, ev);                 } else if self.snaps()                     && !self.smoothing                     && self.points.len() > 1diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs--- a/crates/tools/src/draw2/gesture/tests.rs+++ b/crates/tools/src/draw2/gesture/tests.rs@@ -64,6 +64,36 @@ fn paintbrush_sends_pen_pressure() {     assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); } +/// #764: the Paintbrush sends each point's pen tilt and barrel rotation where the pen reads+/// them, `null` for what a point lacks; the Pencil sends plain points.+#[test]+fn paintbrush_sends_pen_tilt_and_rotation() {+    let (d, _) = doc_with_rect();+    let s = Selection::default();+    let p = paint();+    let cx = cx(&d, &s, &p);+    let stroke = |id: &str, pressure: f32, poses: [Option<PenPose>; 3]| {+        let mut t = GestureTool::new(id);+        let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up];+        let mut last = vec![];+        for ((k, x), pen) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(poses) {+            last = t.pointer(&cx, &PointerEvent { pressure, pen, ..PointerEvent::new(k, x, 0.0) });+        }+        let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") };+        v["points"].clone()+    };+    let leaning = |x: f32, r: Option<f32>| Some(PenPose { tilt: Some([x, -10.0]), rotation: r });+    assert_eq!(+        stroke("paintbrush", 0.5, [leaning(30.0, Some(45.0)), leaning(40.0, Some(-90.0)), None]),+        json!([[0.0, 0.0, 0.5, 30.0, -10.0, 45.0], [40.0, 0.0, 0.5, 40.0, -10.0, 270.0], [80.0, 0.0, 0.5, null, null, null]])+    );+    // A pen without a rotation axis, pressing fully: no pressure, the tilt alone.+    assert_eq!(stroke("paintbrush", 1.0, [leaning(30.0, None); 3])[1], json!([40.0, 0.0, null, 30.0, -10.0, null]));+    // A pose that reads nothing is a mouse's.+    assert_eq!(stroke("paintbrush", 1.0, [Some(PenPose::default()); 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));+    assert_eq!(stroke("pencil", 0.5, [leaning(30.0, Some(45.0)); 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));+}+ #[test] fn pencil_previews_and_commits() {     let (d, _) = doc_with_rect();diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs--- a/crates/tools/src/input.rs+++ b/crates/tools/src/input.rs@@ -40,15 +40,41 @@ pub struct PointerEvent {     pub mods: Mods,     #[serde(default = "one")]     pub pressure: f32,+    /// The pen's tilt and barrel rotation, when the pointer is a pen that reads either (a mouse:+    /// `None`).+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub pen: Option<PenPose>, }  fn one() -> f32 {     1.0 } +/// How a pen is held, as far as it reads it. Tilt is in degrees, each −90..90 as Pointer Events+/// give it, in document axes: `[x, y]`, x positive when the pen leans to the right and y positive+/// when it leans down the page. Rotation is the barrel's, in degrees clockwise (0..360).+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]+pub struct PenPose {+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub tilt: Option<[f32; 2]>,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub rotation: Option<f32>,+}++impl PenPose {+    /// Within its ranges: each tilt is clamped to −90..90 and the rotation brought into 0..360; a+    /// tilt or rotation that is not a number is left out.+    pub fn sanitized(self) -> Self {+        let tilt = self.tilt.filter(|t| t.iter().all(|v| v.is_finite())).map(|t| t.map(|v| v.clamp(-90.0, 90.0)));+        // `rem_euclid` can round a tiny negative angle up to 360 itself.+        let rotation = self.rotation.filter(|r| r.is_finite()).map(|r| r.rem_euclid(360.0)).map(|r| if r >= 360.0 { 0.0 } else { r });+        Self { tilt, rotation }+    }+}+ impl PointerEvent {     pub fn new(kind: PointerKind, x: f64, y: f64) -> Self {-        Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 }+        Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0, pen: None }     }     pub fn with_mods(mut self, m: Mods) -> Self {         self.mods = m;@@ -58,6 +84,18 @@ impl PointerEvent {     pub fn json_pressure(e: &Value) -> f32 {         e.get("pressure").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(1.0, |f| f.clamp(0.0, 1.0) as f32)     }+    /// The pen's tilt and rotation of a pointer event given as JSON (control channel, MCP):+    /// `tiltX` and `tiltY` (degrees, −90..90, in document axes; one without the other is 0) and+    /// `rotation` (degrees clockwise, brought into 0..360). Values that are not numbers are left+    /// out; `None` without any.+    pub fn json_pen(e: &Value) -> Option<PenPose> {+        let num = |k: &str| e.get(k).and_then(Value::as_f64).filter(|f| f.is_finite());+        let tilt = |v: Option<f64>| v.map_or(0.0, |v| v.clamp(-90.0, 90.0) as f32);+        let (tx, ty) = (num("tiltX"), num("tiltY"));+        let tilt = (tx.is_some() || ty.is_some()).then(|| [tilt(tx), tilt(ty)]);+        let pose = PenPose { tilt, rotation: num("rotation").map(|r| r.rem_euclid(360.0) as f32) }.sanitized();+        (pose != PenPose::default()).then_some(pose)+    }     /// How long the pointer then holds still, in seconds, from a JSON pointer event's `holdMs`     /// (0..60000; default 0): the time [`Tool::tick`] gets.     pub fn json_hold(e: &Value) -> f64 {diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs--- a/crates/tools/src/lib.rs+++ b/crates/tools/src/lib.rs@@ -57,7 +57,7 @@ mod input;  pub use context::{PaintDefaults, ScreenFrame, ToolContext}; pub use cursor::Cursor;-pub use input::{Action, Mods, PointerEvent, PointerKind, ToolKey};+pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey};  /// Visual feedback drawn by the UI in screen space (coordinates here are document space). #[derive(Clone, Debug, PartialEq)]diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -6,7 +6,7 @@ use serde_json::json; use vectorcraft_doc::text::TextKind; use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit}; use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};-use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};+use vectorcraft_tools::{Cursor, Mods, Overlay, PenPose, PointerEvent, PointerKind};  use crate::opening; use crate::state::View;@@ -36,7 +36,7 @@ pub use cursor::{HOVER_STILL_SECS, STILL_SECS, ViewKey}; use cursor::{cursor_icon, hover_cursor, hover_wait, still_wait}; use drag::drag_id; pub use drag::{apply_requests, dispatch, mods};-pub(crate) use drag::{end_document_drag, release_art_in_window, release_over_panel};+pub(crate) use drag::{end_document_drag, pose_on_page, release_art_in_window, release_over_panel}; #[cfg(test)] use drag::{frame_moves, tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_press_doc_id}; #[cfg(test)]diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs--- a/crates/ui-egui/src/canvas/drag.rs+++ b/crates/ui-egui/src/canvas/drag.rs@@ -98,13 +98,34 @@ pub(super) fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32     p } +/// The tilt and rotation of the pen in the host's feed while a pen is the pointer that moved last+/// ([`crate::Services::stylus`], [`StylusFeed::pose`]), its tilt turned onto the page+/// ([`pose_on_page`]); `None` for a mouse, or a pen that reads neither.+pub(super) fn pen_pose(app: &VectorcraftApp, xf: &Xf) -> Option<PenPose> {+    app.services.stylus.as_ref().and_then(StylusFeed::pose).map(|q| pose_on_page(q, xf))+}++/// `pose` with its tilt, given in screen axes, turned into the document's (the view rotated by+/// Rotate View): the pen leans the same way on the page as on the screen. The rotation is kept.+pub(crate) fn pose_on_page(pose: PenPose, xf: &Xf) -> PenPose {+    let tilt = pose.tilt.map(|[x, y]| {+        // The direction the pen leans in, as `to_doc` turns a screen offset.+        let (tx, ty) = (f64::from(x).to_radians().tan(), f64::from(y).to_radians().tan());+        let (sn, cs) = xf.rot.sin_cos();+        let (u, v) = (tx * cs + ty * sn, -tx * sn + ty * cs);+        [u.atan().to_degrees() as f32, v.atan().to_degrees() as f32]+    });+    PenPose { tilt, ..pose }+}+ /// The positions this frame's pointer moves passed through before its last one, each with the /// pen force a touch event gave just before it, or the pressure the host's feed kept for that move-/// ([`StylusFeed::take_moves`], macOS), if any. The last move is the pointer's position. A mouse's-/// moves count too, with no pressure: a fast mouse stroke otherwise keeps one position a frame, a-/// sample every few pixels, too few for a low Fidelity to follow the hand (#3328).-pub(super) fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option<f32>)> {-    let pressures = stylus.map(StylusFeed::take_moves).unwrap_or_default();+/// ([`StylusFeed::take_moves`], macOS), if any, and the tilt and rotation the feed kept for that+/// move (empty when the pen read neither), if any. The last move is the pointer's position. A+/// mouse's moves count too, with no pressure: a fast mouse stroke otherwise keeps one position a+/// frame, a sample every few pixels, too few for a low Fidelity to follow the hand (#3328).+pub(super) fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option<f32>, Option<PenPose>)> {+    let samples = stylus.map(StylusFeed::take_moves).unwrap_or_default();     ui.input(|i| {         let mut moves = vec![];         if i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) {@@ -112,19 +133,19 @@ pub(super) fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Op             for e in &i.events {                 match e {                     egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)),-                    egui::Event::PointerMoved(p) => moves.push((*p, force)),+                    egui::Event::PointerMoved(p) => moves.push((*p, force, None)),                     _ => {}                 }             }-        } else if !pressures.is_empty() {-            // The feed's last pressure is the last move's: pair them from the end. A lift's 0 is+        } else if !samples.is_empty() {+            // The feed's last sample is the last move's: pair them from the end. A lift's 0 is             // no pressure (the press keeps its last touch).             let points: Vec<Pos2> = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect();-            let forces = pressures.iter().rev().map(|f| (*f > 0.0).then_some(*f)).chain(std::iter::repeat(None));-            moves = points.into_iter().rev().zip(forces).collect();+            let kept = samples.iter().rev().map(|&(f, q)| ((f > 0.0).then_some(f), Some(q)));+            moves = points.into_iter().rev().zip(kept.chain(std::iter::repeat((None, None)))).map(|(p, (f, q))| (p, f, q)).collect();             moves.reverse();         } else {-            moves = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some((*p, None)) } else { None }).collect();+            moves = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some((*p, None, None)) } else { None }).collect();         }         moves.pop();         // A burst of events (a stalled frame) is thinned to a bounded count.@@ -141,7 +162,7 @@ pub(super) fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Op /// all of them for a tool that builds a stroke from them ([`vectorcraft_engine::Session::tool_wants_samples`]), none /// for the others, which get one drag a frame. The moves are read either way, so the host's feed /// pairs the next frame's with its own.-pub(super) fn tool_frame_moves(ui: &Ui, app: &VectorcraftApp) -> Vec<(Pos2, Option<f32>)> {+pub(super) fn tool_frame_moves(ui: &Ui, app: &VectorcraftApp) -> Vec<(Pos2, Option<f32>, Option<PenPose>)> {     let moves = frame_moves(ui, app.services.stylus.as_ref());     if app.session.tool_wants_samples() { moves } else { vec![] } }@@ -159,7 +180,7 @@ pub(super) fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_en             // and would otherwise outlive the drag that owns it (#2826).             // Nothing to roll back without a document; the tool still gets its mouse-up.             let _ = app.session.cancel_interaction();-            dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);+            dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0, pen: None }, view);             egui::DragAndDrop::clear_payload(ui.ctx());         }         Some(_) => {}@@ -233,7 +254,7 @@ fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) {     // With no document open there is nothing to roll back; the tool still gets its mouse-up.     let _ = app.session.cancel_interaction();     let view = app.view_info();-    dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);+    dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0, pen: None }, view);     ctx.data_mut(|d| {         d.remove::<Drag>(drag_id());         d.remove::<bool>(drag_id().with("art-gone"));diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs--- a/crates/ui-egui/src/canvas/grid.rs+++ b/crates/ui-egui/src/canvas/grid.rs@@ -199,7 +199,13 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c             // The new guide shows even if guides were hidden.             app.ui.view.guides = true;         }-        let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 };+        let ev = PointerEvent {+            kind,+            pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)),+            mods: mods(ui.input(|i| i.modifiers), false),+            pressure: 1.0,+            pen: None,+        };         if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) {             app.status(e.to_string());         }diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs--- a/crates/ui-egui/src/canvas/input.rs+++ b/crates/ui-egui/src/canvas/input.rs@@ -1,7 +1,7 @@ use super::artboards::{artboard_name_at, begin_artboard_name_edit, name_press_id, select_named_artboard}; use super::drag::{-    HoverContext, apply_requests, dispatch, drag_art_out, drag_id, drag_layer_id, drag_pos_id, drop_press, mods, on_canvas, pen_pressure,-    tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id,+    HoverContext, apply_requests, dispatch, drag_art_out, drag_id, drag_layer_id, drag_pos_id, drop_press, mods, on_canvas, pen_pose, pen_pressure,+    pose_on_page, tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id, }; use super::view::{     HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn,@@ -158,7 +158,13 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo                 Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space }             } else {                 // Cmd with another tool drags with the selection tool used last (the session lends it).-                let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) };+                let ev = PointerEvent {+                    kind: PointerKind::Down,+                    pos: xf.to_doc(p),+                    mods: mods(m, space),+                    pressure: pen_pressure(ui, app, true),+                    pen: pen_pose(app, &xf),+                };                 dispatch(app, &ev, view);                 crate::document_drag::begin(app, ui.ctx(), ev.pos);                 Drag::Tool@@ -232,8 +238,13 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo                 Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => {                     // Back on the canvas: the move goes on from where it left, with the pointer.                     egui::DragAndDrop::clear_payload(ui.ctx());-                    let ev =-                        PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };+                    let ev = PointerEvent {+                        kind: PointerKind::Drag,+                        pos: xf.to_doc(p),+                        mods: mods(m, space),+                        pressure: pen_pressure(ui, app, false),+                        pen: pen_pose(app, &xf),+                    };                     dispatch(app, &ev, view);                     ui.data_mut(|dd| {                         dd.insert_temp(drag_id(), Drag::Tool);@@ -254,12 +265,22 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo                     let moves = tool_frame_moves(ui, app);                     // A frame whose pointer ends where the last one did still moved in between.                     if tool_drag_moved(ui, &xf, p, &pointer) || !moves.is_empty() {-                        for (at, force) in moves {+                        for (at, force, pose) in moves {                             let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false));-                            dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view);+                            // The pose the feed kept for this move, else the latest.+                            let pen = match pose {+                                Some(q) => (q != PenPose::default()).then(|| pose_on_page(q, &xf)),+                                None => pen_pose(app, &xf),+                            };+                            dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure, pen }, view);                         }-                        let ev =-                            PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };+                        let ev = PointerEvent {+                            kind: PointerKind::Drag,+                            pos: xf.to_doc(p),+                            mods: mods(m, space),+                            pressure: pen_pressure(ui, app, false),+                            pen: pen_pose(app, &xf),+                        };                         dispatch(app, &ev, view);                     }                     // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts@@ -315,27 +336,38 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo                     if let Some(last) = last_drag                         && last != xf.to_doc(p)                     {-                        let ev =-                            PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };+                        let ev = PointerEvent {+                            kind: PointerKind::Drag,+                            pos: xf.to_doc(p),+                            mods: mods(m, space),+                            pressure: pen_pressure(ui, app, false),+                            pen: pen_pose(app, &xf),+                        };                         dispatch(app, &ev, view);                     }-                    let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };+                    let ev = PointerEvent {+                        kind: PointerKind::Up,+                        pos: xf.to_doc(p),+                        mods: mods(m, space),+                        pressure: pen_pressure(ui, app, false),+                        pen: pen_pose(app, &xf),+                    };                     dispatch(app, &ev, view);                 }                 Drag::Art if on_canvas(ui, resp, p) => {                     // Came back to the canvas and released in one frame: finish the move here.                     egui::DragAndDrop::clear_payload(ui.ctx());-                    let pressure = pen_pressure(ui, app, false);+                    let (pressure, pen) = (pen_pressure(ui, app, false), pen_pose(app, &xf));                     let pos = xf.to_doc(p);-                    dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view);-                    dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view);+                    dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure, pen }, view);+                    dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure, pen }, view);                 }                 Drag::Art => {                     // Released over a panel: the move goes back. (The app ends it before the panels                     // see the release, so a panel can take the art; this is the headless path.)                     // With no document open there is nothing to roll back; the tool still gets its up.                     let _ = app.session.cancel_interaction();-                    dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);+                    dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0, pen: None }, view);                 }                 Drag::Gesture | Drag::Present => {}                 Drag::Pan { .. } | Drag::RotateView { .. } => {}@@ -344,7 +376,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo     } else if let Some(p) = hover_tool         && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover()))     {-        let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };+        let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0, pen: None };         dispatch(app, &ev, view);     }     // The pointer left the canvas (for a panel, or out of the window) or went under a floating@@ -373,7 +405,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo         if let Some(i) = artboard_name_at(app, ui, &xf, p) {             begin_artboard_name_edit(ui, app, i);         } else {-            let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };+            let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0, pen: None };             let before = app.session.tool_id().to_string();             dispatch(app, &ev, view);             // A double-click on type switched a selection tool to the Type tool: the caret goes wherediff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs--- a/crates/ui-egui/src/canvas/tests.rs+++ b/crates/ui-egui/src/canvas/tests.rs@@ -554,7 +554,7 @@ fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() {     let view = app.view_info();     let at = boards[2].center();     for kind in [PointerKind::Down, PointerKind::Up] {-        dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view);+        dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0, pen: None }, view);     }     assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");     let ids: Vec<vectorcraft_doc::NodeId> = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"]@@ -587,7 +587,7 @@ fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {     let view = app.view_info();     let shift = Mods { shift: true, ..Default::default() };     for kind in [PointerKind::Down, PointerKind::Up] {-        dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);+        dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0, pen: None }, view);     }     assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");     assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");@@ -642,7 +642,7 @@ fn the_active_artboard_is_outlined_in_black() {     let view = app.view_info();     let shift = Mods { shift: true, ..Default::default() };     for kind in [PointerKind::Down, PointerKind::Up] {-        dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0 }, view);+        dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0, pen: None }, view);     }     assert_eq!(app.session.tool_options()["selected"], json!([2, 1]));     assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected");@@ -5559,25 +5559,30 @@ fn frame_moves_keeps_touch_feed_and_mouse_samples_with_their_pressure() {     let mut mouse = vec![];     let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| mouse = frame_moves(ui, None));     out.textures_delta.clear();-    // A mouse keeps its moves too, without a pressure.-    assert_eq!(mouse, [(a, None), (b, None)], "the last move follows the normal pointer path");-    // A macOS pen: the feed kept a pressure for each move, the last for the last.+    // A mouse keeps its moves too, without a pressure or a pose.+    assert_eq!(mouse, [(a, None, None), (b, None, None)], "the last move follows the normal pointer path");+    // A macOS pen: the feed kept a pressure and a pose for each move, the last for the last.     let feed = StylusFeed::default();-    for p in [0.9, 0.3, 0.0, 0.5] {-        feed.set_move(crate::stylus::PenSample { pressure: p, eraser: false });+    let leaning = PenPose { tilt: Some([30.0, 0.0]), rotation: None };+    for (p, pose) in [(0.9, leaning), (0.3, leaning), (0.0, PenPose::default()), (0.5, leaning)] {+        feed.set_move(crate::stylus::PenSample { pressure: p, pose, ..Default::default() });     }     let mut samples = vec![];     let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));     out.textures_delta.clear();-    assert_eq!(samples, [(a, Some(0.3)), (b, None)], "paired from the end; a lift's 0 is no pressure");+    assert_eq!(+        samples,+        [(a, Some(0.3), Some(leaning)), (b, None, Some(PenPose::default()))],+        "paired from the end, each with its own pose; a lift's 0 is no pressure"+    );     let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));     out.textures_delta.clear();-    assert_eq!(samples, [(a, None), (b, None)], "the moves were taken: a mouse's moves again");+    assert_eq!(samples, [(a, None, None), (b, None, None)], "the moves were taken: a mouse's moves again");     let events = [pen(egui::TouchPhase::Move, a, 0.2), pen(egui::TouchPhase::Move, b, 0.8), pen(egui::TouchPhase::Move, c, 0.6)].concat();     let mut samples = vec![];     let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));     out.textures_delta.clear();-    assert_eq!(samples, [(a, Some(0.2)), (b, Some(0.8))], "the last sample follows the normal pointer path");+    assert_eq!(samples, [(a, Some(0.2), None), (b, Some(0.8), None)], "the last sample follows the normal pointer path");     let events = (0..200).flat_map(|n| pen(egui::TouchPhase::Move, pos2(n as f32, 0.0), 0.5)).collect();     let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));     out.textures_delta.clear();@@ -5790,7 +5795,7 @@ fn pen_app() -> (VectorcraftApp, StylusFeed, egui::Context) {  /// The pen turned to its eraser end, with `pressure`. fn eraser_end(pressure: f32) -> Option<crate::stylus::PenSample> {-    Some(crate::stylus::PenSample { pressure, eraser: true })+    Some(crate::stylus::PenSample { pressure, eraser: true, ..Default::default() }) }  /// #764: turning the pen to its eraser end selects the Eraser, and turning it back to the tip@@ -5969,6 +5974,54 @@ fn the_tip_brings_back_the_perspective_grid_tool_without_showing_the_grid() {     assert!(!visible(&app), "the grid stays hidden"); } +/// #764: the pen's tilt and barrel rotation in the host's feed reach the Paintbrush's points,+/// the tilt turned onto the page when the view is rotated; a mouse's points carry neither.+#[test]+fn the_hosts_pen_tilt_and_rotation_reach_the_paintbrush() {+    let mut app = VectorcraftApp::new(Session::new(), Default::default());+    let feed = StylusFeed::default();+    app.services.stylus = Some(feed.clone());+    app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();+    let ctx = egui::Context::default();+    frame(&mut app, &ctx, vec![]);+    app.select_tool("paintbrush");+    // A stroke at `y`, one frame per sample, with the pen held as `pose` (`None`: a mouse); the+    // tilt x, tilt y and rotation of each of its points.+    let stroke = |app: &mut VectorcraftApp, y: f64, pose: Option<PenPose>| -> Vec<[Option<f64>; 3]> {+        let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());+        let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };+        let mut at = Pos2::ZERO;+        for (i, x) in [360.0, 400.0, 440.0].into_iter().enumerate() {+            match pose {+                Some(pose) => feed.set_sample(Some(crate::stylus::PenSample { pressure: 0.5, pose, ..Default::default() })),+                None => feed.set(None),+            }+            at = xf.to_screen(Point::new(x, y));+            let mut events = vec![egui::Event::PointerMoved(at)];+            if i == 0 {+                events.push(button(at, true));+            }+            frame(app, &ctx, events);+        }+        frame(app, &ctx, vec![button(at, false)]);+        let (cmd, p) = app.session.journal.last().unwrap().clone();+        assert_eq!(cmd, "path.freehand");+        p["points"].as_array().unwrap().iter().map(|s| [3, 4, 5].map(|i| s.get(i).and_then(serde_json::Value::as_f64))).collect()+    };+    let leaning = PenPose { tilt: Some([30.0, 0.0]), rotation: Some(45.0) };+    let points = stroke(&mut app, 260.0, Some(leaning));+    assert!(!points.is_empty() && points.iter().all(|p| *p == [Some(30.0), Some(0.0), Some(45.0)]), "{points:?}");+    // With the view rotated 90° clockwise, a pen leaning to the right of the screen leans up the+    // page; the barrel's rotation stays as it was.+    app.run("view.rotate", json!({"angle": 90})).unwrap();+    frame(&mut app, &ctx, vec![]);+    let points = stroke(&mut app, 340.0, Some(leaning));+    let up = |p: &[Option<f64>; 3]| matches!(*p, [Some(x), Some(y), Some(r)] if x.abs() < 0.01 && (y + 30.0).abs() < 0.01 && r == 45.0);+    assert!(!points.is_empty() && points.iter().all(up), "{points:?}");+    let points = stroke(&mut app, 300.0, None);+    assert!(!points.is_empty() && points.iter().all(|p| *p == [None; 3]), "a mouse: {points:?}");+}+ /// #3328: every position a mouse reports within one frame reaches the Pencil, not only the /// frame's last one, so a fast stroke keeps the hand's movement. #[test]diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs--- a/crates/ui-egui/src/control.rs+++ b/crates/ui-egui/src/control.rs@@ -275,7 +275,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques                 };                 let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);                 let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);-                let pos = if e.get("space").and_then(Value::as_str) == Some("screen") {+                let screen = e.get("space").and_then(Value::as_str) == Some("screen");+                let pos = if screen {                     match xf {                         Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)),                         None => return err("no canvas yet"),@@ -284,7 +285,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques                     vectorcraft_geom::Point::new(x, y)                 };                 let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);-                crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view);+                // A tilt given in screen axes leans the same way on the page as on the screen.+                let pen = PointerEvent::json_pen(e).map(|q| match xf {+                    Some(xf) if screen => crate::canvas::pose_on_page(q, &xf),+                    _ => q,+                });+                crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e), pen }, view);                 let hold = PointerEvent::json_hold(e);                 if hold > 0.0 {                     let r = app.session.tool_tick(hold, view);diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs--- a/crates/ui-egui/src/dialogs/brush_art_options.rs+++ b/crates/ui-egui/src/dialogs/brush_art_options.rs@@ -8,7 +8,8 @@ //! `tintsAndShades` | `hueShift`), `keyColor` (a color), `tips` (whether Colorization Tips is //! open). //! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode`-//! (`fixed` | `random` | `pressure`), `rotationRelativeTo` (`page` | `path`).+//! (`fixed` | `random` | `pressure` | `tilt` | `bearing` | `rotation`), `rotationRelativeTo` (`page` |+//! `path`). //! Art: `width` (%), `widthMode` (`fixed` | `pressure`), `widthMax` (the width at the heaviest pen //! pressure, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and //! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`,diff --git a/crates/ui-egui/src/dialogs/brush_options.rs b/crates/ui-egui/src/dialogs/brush_options.rs--- a/crates/ui-egui/src/dialogs/brush_options.rs+++ b/crates/ui-egui/src/dialogs/brush_options.rs@@ -1,7 +1,7 @@ //! Brush Options (double-click a brush in the Brushes panel, or the panel menu's Brush Options…).-//! Calligraphic Brush Options: Name, then Angle, Roundness and Size, each Fixed, Random or-//! Pressure with its Variation, over live previews of the nib and of a stroke (light, heavy, light-//! pressure). Bristle Brush Options: Name (typing stops at [`Bristle::MAX_NAME_CHARS`]+//! Calligraphic Brush Options: Name, then Angle, Roundness and Size, each Fixed, Random, Pressure,+//! Tilt, Bearing or Rotation with its Variation, over live previews of the nib and of a stroke+//! (light, heavy, light pressure). Bristle Brush Options: Name (typing stops at [`Bristle::MAX_NAME_CHARS`] //! characters), Shape and the bristle sliders. Scatter, Art and Pattern Brush Options: see //! [`super::brush_art_options`]. OK runs `brush.options` (strokes painted with the brush update); //! a name another brush has, or a new bristle brush name that is too long, keeps the dialog open.@@ -9,7 +9,8 @@ //! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle` | `scatter` | `art` | //! `pattern`), `name`. //! Calligraphic: `angle` (−180..180°), `roundness` (0..100 %), `size` (0..1296 pt), and per value-//! `angleMode`, `roundnessMode`, `sizeMode` (`fixed` | `random` | `pressure`) and+//! `angleMode`, `roundnessMode`, `sizeMode` (`fixed` | `random` | `pressure` | `tilt` | `bearing` |+//! `rotation`) and //! `angleVariation`, `roundnessVariation`, `sizeVariation`. Bristle: `shape` (`roundPoint`…), //! `size` (pt), `length` (25..300 %), `density`, `thickness`, `opacity`, `stiffness` (1..100 %). //!@@ -331,9 +332,9 @@ fn nib_preview(ui: &mut egui::Ui, c: &Calligraphic, size: egui::Vec2) {     let at = |sign: f64| -> [f64; 3] {         let mut v = values;         for (i, (x, mode)) in v.iter_mut().zip(c.modes()).enumerate() {-            let (Some(&var), Some(&(lo, hi))) = (c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };+            let Some(&var) = c.variation.get(i) else { continue };             if mode != Variation::Fixed {-                *x = (*x + sign * var).clamp(lo, hi);+                *x = Calligraphic::moved(i, *x, sign * var);             }         }         vdiff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs--- a/crates/ui-egui/src/dialogs/brush_options/tests.rs+++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs@@ -41,6 +41,31 @@ fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them()     assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0)); } +/// #764: Calligraphic and Scatter Brush Options show Tilt, Bearing and Rotation, and OK sets+/// them.+#[test]+fn brush_options_set_tilt_bearing_and_rotation() {+    let mut app = app();+    app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();+    set(&mut app, json!({"angleMode": "bearing", "roundnessMode": "tilt", "sizeMode": "rotation"}));+    let text = shown(&mut app);+    for label in ["Bearing", "Tilt", "Rotation"] {+        assert!(text.contains(label), "{label} in {text}");+    }+    super::super::confirm(&mut app).unwrap();+    let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap();+    assert_eq!(def["modes"], json!(["bearing", "tilt", "rotation"]));+    app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();+    set(&mut app, json!({"sizeMode": "tilt", "spacingMode": "bearing", "scatterMode": "rotation", "rotationMode": "bearing"}));+    let text = shown(&mut app);+    for label in ["Tilt", "Bearing"] {+        assert!(text.contains(label), "{label} in {text}");+    }+    super::super::confirm(&mut app).unwrap();+    let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap();+    assert_eq!(def["modes"], json!(["tilt", "bearing", "rotation", "bearing"]));+}+ #[test] fn a_taken_name_keeps_the_dialog_open() {     let mut app = app();diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs--- a/crates/ui-egui/src/dialogs/gradient_stop.rs+++ b/crates/ui-egui/src/dialogs/gradient_stop.rs@@ -341,7 +341,11 @@ mod tests {         let mut app = app();         let view = app.view_info();         let chip = Point::new(150.0, 150.0 + 10.0 / view.zoom);-        crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view);+        crate::canvas::dispatch(+            &mut app,+            &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0, pen: None },+            view,+        );         let d = app.ui.dialog.clone().expect("the popover opened");         assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into()));         assert_eq!(app.session.selected_stop(), Some(1));@@ -441,7 +445,11 @@ mod tests {         let (p, i) = point(&app);         // Double-clicking the point opens the popover beside it.         let view = app.view_info();-        crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), pressure: 1.0 }, view);+        crate::canvas::dispatch(+            &mut app,+            &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), pressure: 1.0, pen: None },+            view,+        );         let d = app.ui.dialog.clone().expect("the popover opened");         assert_eq!((d.kind.as_str(), d.fields["index"].clone()), ("gradientStop", json!(i)));         frame(&mut app, vec![]);diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs--- a/crates/ui-egui/src/dialogs/tests_screens.rs+++ b/crates/ui-egui/src/dialogs/tests_screens.rs@@ -113,7 +113,7 @@ fn opens_with_the_artboards_the_artboard_tool_selected_and_exports_only_them() {     let click = |app: &mut VectorcraftApp, i: usize, shift: bool| {         let mods = Mods { shift, ..Default::default() };         for kind in [PointerKind::Down, PointerKind::Up] {-            crate::canvas::dispatch(app, &PointerEvent { kind, pos: boards[i].center(), mods, pressure: 1.0 }, view);+            crate::canvas::dispatch(app, &PointerEvent { kind, pos: boards[i].center(), mods, pressure: 1.0, pen: None }, view);         }     };     // A click on artboard 2 and a Shift-click on artboard 3 select those two. The dialog opens withdiff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv--- a/crates/ui-egui/src/i18n/bg.tsv+++ b/crates/ui-egui/src/i18n/bg.tsv@@ -1924,6 +1924,8 @@ font-technology	Variable	Променливи 	Roundness:	Закръгленост: 	Fixed	Фиксирано 	Pressure	Натиск+	Tilt	Наклон+	Bearing	Азимут 	Shape:	Форма: 	Bristle Length:	Дължина на косъма: 	Bristle Density:	Плътност на косъмчетата:diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -2943,6 +2943,7 @@ lighting	Light	Světlo  # Command labels and other interface strings 	Art	Obrazový+	Bearing	Azimut 	Bristle	Štětinový 	Calligraphic	Kaligrafický 	Dots	Body@@ -2950,6 +2951,7 @@ lighting	Light	Světlo 	Pressure	Tlak 	Random	Náhodný 	Scatter	Rozptylový+	Tilt	Náklon 	Color Burn	Ztmavit barvy 	Color Dodge	Zesvětlit barvy 	Darken	Ztmavitdiff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv--- a/crates/ui-egui/src/i18n/da.tsv+++ b/crates/ui-egui/src/i18n/da.tsv@@ -2714,6 +2714,7 @@ lighting	Light	Lys 	Total Width:	Samlet bredde: 	Width Point Edit	Redigér breddepunkt 	Art	Kunst+	Bearing	Retning 	Bristle	Borst 	Calligraphic	Kalligrafisk 	Dots	Prikker@@ -2721,6 +2722,7 @@ lighting	Light	Lys 	Pressure	Tryk 	Random	Tilfældig 	Scatter	Spredning+	Tilt	Hældning 	Color Burn	Farvebrænding 	Color Dodge	Farveudtynding 	Darken	Mørkerediff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -1938,6 +1938,8 @@ file-filter	Templates	Vorlagen 	Roundness:	Rundheit: 	Fixed	Fest 	Pressure	Druck+	Tilt	Neigung+	Bearing	Richtung 	Shape:	Form: 	Bristle Length:	Borstenlänge: 	Bristle Density:	Borstendichte:diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv--- a/crates/ui-egui/src/i18n/el.tsv+++ b/crates/ui-egui/src/i18n/el.tsv@@ -1653,6 +1653,8 @@ font-technology	Variable	Μεταβλητές 	Roundness:	Στρογγυλότητα: 	Fixed	Σταθερό 	Pressure	Πίεση+	Tilt	Κλίση+	Bearing	Αζιμούθιο 	Shape:	Σχήμα: 	New Brush…	Νέο πινέλο… 	New Brush	Νέο πινέλοdiff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -1976,6 +1976,8 @@ font-technology	Variable	Variables 	Roundness:	Redondez: 	Fixed	Fijo 	Pressure	Presión+	Tilt	Inclinación+	Bearing	Orientación 	Shape:	Forma: 	Bristle Length:	Longitud de cerdas: 	Bristle Density:	Densidad de cerdas:diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv--- a/crates/ui-egui/src/i18n/fi.tsv+++ b/crates/ui-egui/src/i18n/fi.tsv@@ -2887,6 +2887,7 @@ lighting	Light	Valo 	Total Width:	Kokonaisleveys: 	Width Point Edit	Leveyspisteen muokkaus 	Art	Kuvitus+	Bearing	Suunta 	Bristle	Harjas 	Calligraphic	Kalligrafinen 	Dots	Pisteet@@ -2894,6 +2895,7 @@ lighting	Light	Valo 	Pressure	Paine 	Random	Satunnainen 	Scatter	Sironta+	Tilt	Kallistus 	Color Burn	Värisävyn tummennus 	Color Dodge	Värisävyn vaalennus 	Darken	Tummennadiff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -1973,6 +1973,8 @@ font-technology	Variable	Variables 	Roundness:	Arrondi : 	Fixed	Fixe 	Pressure	Pression+	Tilt	Inclinaison+	Bearing	Orientation 	Shape:	Forme : 	Bristle Length:	Longueur des soies : 	Bristle Density:	Densité des soies :diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -395,6 +395,7 @@ font-technology	Variable	Változó 	Basic	Alap 	Basic Latin	Alap latin 	Batch	Kötegelt feldolgozás+	Bearing	Irány 	Begin Recording	Felvétel indítása 	Begin Undo Group	Visszavonási csoport kezdete 	Bend:	Hajlítás mértéke:@@ -4005,6 +4006,7 @@ mark-style	Japanese	Japán 	Tile Type:	Csempézés módja: 	tile {number}	csempe {number} 	Tiles are numbered across, then down; leave the range empty for every tile.	A csempék számozása vízszintesen, majd lefelé halad; az összes csempéhez hagyd üresen a tartományt.+	Tilt	Dőlés 	Tint	Tónus 	Tints	Tónusok 	Tints and Shades	Tónusok és árnyalatokdiff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv--- a/crates/ui-egui/src/i18n/id.tsv+++ b/crates/ui-egui/src/i18n/id.tsv@@ -2714,6 +2714,7 @@ lighting	Light	Cahaya 	Total Width:	Lebar total: 	Width Point Edit	Edit titik lebar 	Art	Seni+	Bearing	Arah 	Bristle	Bristle 	Calligraphic	Kaligrafis 	Dots	Titik@@ -2721,6 +2722,7 @@ lighting	Light	Cahaya 	Pressure	Tekanan 	Random	Acak 	Scatter	Sebar+	Tilt	Kemiringan 	Color Burn	Bakar warna 	Color Dodge	Dodge warna 	Darken	Menggelapkandiff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv--- a/crates/ui-egui/src/i18n/it.tsv+++ b/crates/ui-egui/src/i18n/it.tsv@@ -2003,6 +2003,8 @@ file-filter	Templates	Modelli 	Roundness:	Rotondità: 	Fixed	Fisso 	Pressure	Pressione+	Tilt	Inclinazione+	Bearing	Orientamento 	Shape:	Forma: 	Bristle Length:	Lunghezza setole: 	Bristle Density:	Densità setole:diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -2128,6 +2128,8 @@ font-technology	Variable	バリアブル 	Roundness:	真円率: 	Fixed	固定 	Pressure	筆圧+	Tilt	傾き+	Bearing	方位 	Shape:	形状: 	Bristle Length:	毛の長さ: 	Bristle Density:	毛の密度:diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv--- a/crates/ui-egui/src/i18n/ka.tsv+++ b/crates/ui-egui/src/i18n/ka.tsv@@ -2524,6 +2524,7 @@ lighting	Light	სინათლე 	Total Width:	სრული სიგანე: 	Width Point Edit	სიგანის წერტილის რედაქტირება 	Art	ნახატი+	Bearing	აზიმუტი 	Bristle	ჯაგრისი 	Calligraphic	კალიგრაფიული 	Dots	წერტილები@@ -2531,6 +2532,7 @@ lighting	Light	სინათლე 	Pressure	დაწოლა 	Random	შემთხვევითი 	Scatter	გაბნევა+	Tilt	დახრა 	Color Burn	ფერით ჩამუქება 	Color Dodge	ფერით გაღიავება 	Darken	ჩამუქებაdiff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv--- a/crates/ui-egui/src/i18n/ko.tsv+++ b/crates/ui-egui/src/i18n/ko.tsv@@ -2123,6 +2123,8 @@ font-technology	Variable	가변 	Roundness:	원형률: 	Fixed	고정 	Pressure	필압+	Tilt	기울기+	Bearing	방위 	Shape:	모양: 	Bristle Length:	강모 길이: 	Bristle Density:	강모 밀도:diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv--- a/crates/ui-egui/src/i18n/nl.tsv+++ b/crates/ui-egui/src/i18n/nl.tsv@@ -2728,6 +2728,7 @@ lighting	Light	Licht 	Total Width:	Totale breedte: 	Width Point Edit	Breedtepunt bewerken 	Art	Illustratie+	Bearing	Richting 	Bristle	Borstel 	Calligraphic	Kalligrafisch 	Dots	Stippen@@ -2735,6 +2736,7 @@ lighting	Light	Licht 	Pressure	Druk 	Random	Willekeurig 	Scatter	Strooi+	Tilt	Kanteling 	Color Burn	Na branden 	Color Dodge	Kleur ontwijken 	Darken	Donkerderdiff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -2821,6 +2821,7 @@ lighting	Light	Światło 	Total Width:	Całkowita szerokość: 	Width Point Edit	Edycja punktu szerokości 	Art	Artystyczny+	Bearing	Kierunek 	Bristle	Włosiankowy 	Calligraphic	Kaligraficzny 	Dots	Kropki@@ -2828,6 +2829,7 @@ lighting	Light	Światło 	Pressure	Nacisk 	Random	Losowy 	Scatter	Rozproszony+	Tilt	Nachylenie 	Color Burn	Ściemnianie koloru 	Color Dodge	Rozjaśnianie koloru 	Darken	Ciemniejdiff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv--- a/crates/ui-egui/src/i18n/pt-br.tsv+++ b/crates/ui-egui/src/i18n/pt-br.tsv@@ -4466,6 +4466,8 @@ axis	Both	Ambos 	Roundness:	Arredondamento: 	Fixed	Fixo 	Pressure	Pressão+	Tilt	Inclinação+	Bearing	Orientação 	Bristle Length:	Comprimento das Cerdas: 	Bristle Density:	Densidade das Cerdas: 	Bristle Thickness:	Espessura das Cerdas:diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -1999,6 +1999,8 @@ file-filter	Templates	Шаблоны 	Roundness:	Округлость: 	Fixed	Фиксированный 	Pressure	Нажим+	Tilt	Наклон+	Bearing	Азимут 	Shape:	Форма: 	Bristle Length:	Длина щетинок: 	Bristle Density:	Плотность щетинок:diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv--- a/crates/ui-egui/src/i18n/sv.tsv+++ b/crates/ui-egui/src/i18n/sv.tsv@@ -1924,6 +1924,8 @@ font-technology	Variable	Variabla 	Roundness:	Rundhet: 	Fixed	Fast 	Pressure	Tryck+	Tilt	Lutning+	Bearing	Riktning 	Shape:	Form: 	Bristle Length:	Borstlängd: 	Bristle Density:	Borsttäthet:diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv--- a/crates/ui-egui/src/i18n/tr.tsv+++ b/crates/ui-egui/src/i18n/tr.tsv@@ -1810,6 +1810,8 @@ font-technology	Variable	Değişken 	Roundness:	Yuvarlaklık: 	Fixed	Sabit 	Pressure	Basınç+	Tilt	Eğim+	Bearing	Yön 	Shape:	Şekil: 	Bristle Length:	Kıl Uzunluğu: 	Bristle Density:	Kıl Yoğunluğu:diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv--- a/crates/ui-egui/src/i18n/uk.tsv+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -1995,6 +1995,8 @@ file-filter	Templates	Шаблони 	Roundness:	Округлість: 	Fixed	Фіксований 	Pressure	Натиск+	Tilt	Нахил+	Bearing	Азимут 	Shape:	Форма: 	Bristle Length:	Довжина щетинок: 	Bristle Density:	Щільність щетинок:diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv--- a/crates/ui-egui/src/i18n/vi.tsv+++ b/crates/ui-egui/src/i18n/vi.tsv@@ -317,6 +317,7 @@ font-technology	Variable	Biến thiên 	Baseline Shift	Dịch chuyển đường cơ sở 	Basic Latin	Latin cơ bản 	Batch	Hàng loạt+	Bearing	Hướng 	Begin Recording	Bắt đầu ghi 	Begin Undo Group	Bắt đầu nhóm hoàn tác 	Bend:	Uốn cong:@@ -3705,6 +3706,7 @@ mark-style	Japanese	Kiểu Nhật 	Tile Type:	Loại ô: 	tile {number}	ô {number} 	Tiles are numbered across, then down; leave the range empty for every tile.	Các ô được đánh số từ ngang sang dọc; để trống phạm vi để in tất cả ô.+	Tilt	Độ nghiêng 	Tint	Tông màu 	Tints	Sắc pha 	Tints and Shades	Sắc pha và bóngdiff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv--- a/crates/ui-egui/src/i18n/zh-hans.tsv+++ b/crates/ui-egui/src/i18n/zh-hans.tsv@@ -1876,6 +1876,8 @@ file-filter	Templates	模板 	Roundness:	圆度: 	Fixed	固定 	Pressure	压力+	Tilt	倾斜+	Bearing	方位 	Shape:	形状: 	Bristle Length:	鬃毛长度: 	Bristle Density:	鬃毛密度:diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv--- a/crates/ui-egui/src/i18n/zh-hant.tsv+++ b/crates/ui-egui/src/i18n/zh-hant.tsv@@ -2773,6 +2773,8 @@ file-filter	Templates	範本 	Roundness:	圓度: 	Fixed	固定 	Pressure	壓力+	Tilt	傾斜+	Bearing	方位 	Shape:	形狀: 	Bristle Length:	鬃毛長度: 	Bristle Density:	鬃毛密度:diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs--- a/crates/ui-egui/src/panels/brushes.rs+++ b/crates/ui-egui/src/panels/brushes.rs@@ -13,7 +13,7 @@ use std::sync::Arc; use egui::{Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_brush::Brush;-use vectorcraft_doc::{Appearance, Document, Node, PressureProfile, color::Color, color::Paint};+use vectorcraft_doc::{AngleProfile, Appearance, Document, Node, PressureProfile, color::Color, color::Paint}; use vectorcraft_engine::DocState; use vectorcraft_engine::cmd::brushlib::{self, BrushLibrary}; use vectorcraft_engine::cmd::clipboard::{ArtResources, art_resources};@@ -96,7 +96,8 @@ fn preview(ui: &Ui, doc: &Document, def: &Value, key: u64, size: egui::Vec2) -> /// What a stroke preview of brush definition `def`, `w` × `h` pt, renders: a copy of `resources` /// (a document whose patterns, symbols, swatches and images the brush art may use) without its art, /// and a stroke painted with the brush across it. The stroke goes from light to heavy pen pressure-/// and back, so pressure-sensitive brushes show how they vary.+/// and back, from an upright pen to one lying flat and back, and once round every bearing and barrel+/// rotation, so brushes that vary with the pen show how they vary. fn preview_doc(resources: &Document, def: &Value, w: f64, h: f64) -> Option<Document> {     let mut doc = resources.clone();     let layer = doc.alloc_id();@@ -107,6 +108,9 @@ fn preview_doc(resources: &Document, def: &Value, w: f64, h: f64) -> Option<Docu     let st = ap.stroke_mut()?;     st.brush = Some(name);     st.pressure = Some(PressureProfile::sample());+    st.tilt = Some(PressureProfile::sample());+    st.bearing = Some(AngleProfile::sample());+    st.rotation = Some(AngleProfile::sample());     // A gentle S-curve across the swatch (square tiles keep a quarter of it either side).     let mut bp = vectorcraft_geom::BezPath::new();     let margin = (h * 0.5).min(w * 0.25);diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs--- a/crates/ui-egui/src/stylus.rs+++ b/crates/ui-egui/src/stylus.rs@@ -1,15 +1,16 @@-//! Pen pressure that the host reads from the system itself (#764).+//! Pen pressure, tilt and barrel rotation that the host reads from the system itself (#764). //! //! winit 0.30 hands a pen's pressure to egui only on Windows, as the force of a touch event; the-//! web build makes the same touch events from the browser's Pointer Events. On Linux under X11-//! (XWayland included), winit reports a pen as a mouse, so the desktop app reads the pressure-//! from XInput2 on a connection of its own and writes it into a [`StylusFeed`]-//! ([`crate::Services::stylus`]). On macOS, where winit reports none either, the app reads each-//! event's pressure from AppKit before the window handles it, and also keeps it for that event's-//! pointer move ([`StylusFeed::set_move`]), so the moves of a fast stroke each get their own. On-//! Windows, where winit drops which end of the pen is down, the app reads the pressure and the end-//! from each pointer message of a pen before winit handles it. The canvas uses the feed when a-//! frame's events carry no touch force.+//! web build makes the same touch events from the browser's Pointer Events. No platform gets the+//! pen's tilt or rotation through winit. On Linux under X11 (XWayland included), winit reports a+//! pen as a mouse, so the desktop app reads the pressure, tilt and rotation from XInput2 on a+//! connection of its own and writes them into a [`StylusFeed`] ([`crate::Services::stylus`]). On+//! macOS, where winit reports none of them either, the app reads each event's pressure, tilt and+//! rotation from AppKit before the window handles it, and also keeps them for that event's pointer+//! move ([`StylusFeed::set_move`]), so the moves of a fast stroke each get their own. On Windows,+//! where winit drops which end of the pen is down, the app reads the pressure and the end from+//! each pointer message of a pen before winit handles it. The canvas uses the pressure when a+//! frame's events carry no touch force, and the tilt and rotation for each press, drag and release. //! //! The feed also says which end of the pen moved. Turning the pen to its eraser end selects the //! Eraser tool, and turning it back to the tip selects the tool used before@@ -26,6 +27,8 @@ use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::time::Duration; +pub use vectorcraft_tools::PenPose;+ use crate::VectorcraftApp;  /// The tool the pen's eraser end selects.@@ -45,6 +48,8 @@ pub struct PenSample {     pub pressure: f32,     /// The pen is turned to its eraser end.     pub eraser: bool,+    /// The tilt and rotation the pen read, if any.+    pub pose: PenPose, }  /// What the feed holds.@@ -60,8 +65,11 @@ struct Pen {     /// The last pressure above 0 set since a pen was the last pointer to move, with the same end     /// (0 when that end has not touched).     touched: f32,-    /// The pressure of each pointer move set since the UI last took them, oldest first.-    moves: VecDeque<f32>,+    /// The pressure, tilt and rotation of each pointer move set since the UI last took them,+    /// oldest first.+    moves: VecDeque<(f32, PenPose)>,+    /// The pen's latest tilt and rotation (none when a mouse moved last).+    pose: PenPose, }  impl Pen {@@ -80,8 +88,8 @@ struct Shared {     ui_thread: bool, } -/// The pen pressure and pen end the host last read, written from any thread and read on the UI-/// thread. Cheap to clone; the clones share the value.+/// The pen pressure, pen end, tilt and rotation the host last read, written from any thread and+/// read on the UI thread. Cheap to clone; the clones share the value. #[derive(Clone, Default)] pub struct StylusFeed(Arc<Shared>); @@ -99,12 +107,13 @@ impl StylusFeed {     /// A pen turned to its tip moved with `pressure`, or `None`: another pointer moved     /// ([`Self::set_sample`]).     pub fn set(&self, pressure: Option<f32>) {-        self.set_sample(pressure.map(|pressure| PenSample { pressure, eraser: false }));+        self.set_sample(pressure.map(|pressure| PenSample { pressure, ..PenSample::default() }));     } -    /// A pen moved with `sample`'s pressure (0..1; 0 while it hovers) and end, or `None`: another-    /// pointer, such as a mouse, moved. A pressure outside 0..1 is clamped, and one that is not a-    /// number is a mouse's, 1. A sample of the other end starts afresh: the touches of the end+    /// A pen moved with `sample`'s pressure (0..1; 0 while it hovers), end, tilt and rotation, or+    /// `None`: another pointer, such as a mouse, moved. A pressure outside 0..1 is clamped, and one+    /// that is not a number is a mouse's, 1. The tilt and rotation are kept within their ranges by+    /// [`PenPose::sanitized`]. A sample of the other end starts afresh: the touches of the end     /// before it don't count for its presses.     pub fn set_sample(&self, sample: Option<PenSample>) {         let pressure = sample.map(|s| if s.pressure.is_finite() { s.pressure.clamp(0.0, 1.0) } else { 1.0 });@@ -114,6 +123,7 @@ impl StylusFeed {             *pen = Pen::default();         }         pen.eraser = eraser;+        pen.pose = sample.map_or(PenPose::default(), |s| s.pose.sanitized());         match pressure {             Some(p) if p > 0.0 => {                 let touching = pen.latest.is_some_and(|l| l > 0.0);@@ -133,8 +143,8 @@ impl StylusFeed {     }      /// Like [`Self::set_sample`], for a pen sample that comes with one pointer move of the window,-    /// read before the window handles the move (macOS): its pressure is also kept for that move-    /// ([`Self::take_moves`]).+    /// read before the window handles the move (macOS): its pressure, tilt and rotation are also+    /// kept for that move ([`Self::take_moves`]).     pub fn set_move(&self, sample: PenSample) {         self.set_sample(Some(sample));         let mut pen = self.lock();@@ -142,12 +152,13 @@ impl StylusFeed {             pen.moves.pop_front();         }         let pressure = pen.latest.unwrap_or(1.0);-        pen.moves.push_back(pressure);+        let pose = pen.pose;+        pen.moves.push_back((pressure, pose));     } -    /// The pressures of the pointer moves set with [`Self::set_move`] since the last call, oldest-    /// first (256 at most): the last one is the latest move's.-    pub fn take_moves(&self) -> Vec<f32> {+    /// The pressures, tilts and rotations of the pointer moves set with [`Self::set_move`] since+    /// the last call, oldest first (256 at most): the last one is the latest move's.+    pub fn take_moves(&self) -> Vec<(f32, PenPose)> {         self.lock().moves.drain(..).collect()     } @@ -173,6 +184,13 @@ impl StylusFeed {         self.press_within(PRESS_WAIT)     } +    /// The tilt (in screen axes) and rotation of the pen when a pen was the last pointer to move+    /// and it read either, otherwise `None`.+    pub fn pose(&self) -> Option<PenPose> {+        let pen = self.lock();+        pen.latest.map(|_| pen.pose).filter(|q| *q != PenPose::default())+    }+     fn press_within(&self, wait: Duration) -> Option<f32> {         let mut pen = self.lock();         if pen.hovering() && !self.0.ui_thread {@@ -283,17 +301,45 @@ mod tests {         assert_eq!((feed.held(), feed.press()), (None, None), "a mouse moved");     } +    /// The tilt and rotation a pen read, kept within their ranges, until a mouse moves.+    #[test]+    fn the_feed_keeps_the_pens_tilt_and_rotation_within_their_ranges() {+        let feed = StylusFeed::default();+        assert_eq!(feed.pose(), None);+        let pen = |pose| Some(PenSample { pressure: 0.4, pose, ..PenSample::default() });+        let leaning = PenPose { tilt: Some([-120.0, 30.0]), rotation: Some(-90.0) };+        feed.set_sample(pen(leaning));+        assert_eq!(feed.pose(), Some(PenPose { tilt: Some([-90.0, 30.0]), rotation: Some(270.0) }));+        assert_eq!(feed.held(), Some(0.4), "the pressure as before");+        // Not a number: left out. A tiny negative rotation is 0, not 360.+        feed.set_sample(pen(PenPose { tilt: Some([f32::NAN, 0.0]), rotation: Some(-1e-9) }));+        assert_eq!(feed.pose(), Some(PenPose { tilt: None, rotation: Some(0.0) }));+        feed.set_sample(pen(PenPose { tilt: None, rotation: Some(f32::INFINITY) }));+        assert_eq!(feed.pose(), None, "a pen that reads neither");+        assert!(PenPose { rotation: Some(725.0), ..Default::default() }.sanitized().rotation == Some(5.0));+        // The eraser end reads them too.+        feed.set_sample(Some(PenSample { pressure: 0.4, eraser: true, pose: leaning }));+        assert_eq!((feed.eraser(), feed.pose()), (Some(true), Some(PenPose { tilt: Some([-90.0, 30.0]), rotation: Some(270.0) })));+        // A pen that reads no tilt (`set`), or a mouse: none.+        feed.set_sample(pen(leaning));+        feed.set(Some(0.5));+        assert_eq!(feed.pose(), None);+        feed.set_sample(pen(leaning));+        feed.set_sample(None);+        assert_eq!((feed.pose(), feed.held()), (None, None));+    }+     #[test]     fn the_feed_keeps_the_eraser_end() {         let feed = StylusFeed::default();         assert_eq!(feed.eraser(), None, "no pen until one moves");-        feed.set_sample(Some(PenSample { pressure: 1.5, eraser: true }));+        feed.set_sample(Some(PenSample { pressure: 1.5, eraser: true, ..PenSample::default() }));         assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)), "clamped, and the eraser end kept");-        feed.set_sample(Some(PenSample { pressure: f32::NAN, eraser: true }));+        feed.set_sample(Some(PenSample { pressure: f32::NAN, eraser: true, ..PenSample::default() }));         assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));         feed.set(Some(0.25));         assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.25)), "the tip");-        feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true }));+        feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true, ..PenSample::default() }));         assert_eq!(feed.eraser(), Some(true), "the eraser end hovering");         feed.set_sample(None);         assert_eq!(feed.eraser(), None, "a mouse moved");@@ -302,17 +348,24 @@ mod tests {     #[test]     fn the_feed_keeps_the_pressure_of_each_move() {         let feed = StylusFeed::default();-        for p in [0.2, 1.5, 0.4] {-            feed.set_move(PenSample { pressure: p, eraser: false });+        let leaning = PenPose { tilt: Some([20.0, -10.0]), rotation: Some(400.0) };+        for (p, pose) in [(0.2, PenPose::default()), (1.5, leaning), (0.4, PenPose::default())] {+            feed.set_move(PenSample { pressure: p, pose, ..PenSample::default() });         }         feed.set(Some(0.9));-        assert_eq!(feed.take_moves(), [0.2, 1.0, 0.4], "clamped; a sample without a move keeps none");+        let turned = PenPose { rotation: Some(40.0), ..leaning };+        let unread = PenPose::default();+        assert_eq!(+            feed.take_moves(),+            [(0.2, unread), (1.0, turned), (0.4, unread)],+            "clamped, each move with its own pose; a sample without a move keeps none"+        );         assert_eq!((feed.take_moves(), feed.held()), (vec![], Some(0.9)), "taken once");         for _ in 0..MAX_MOVES + 10 {-            feed.set_move(PenSample { pressure: 0.5, eraser: false });+            feed.set_move(PenSample { pressure: 0.5, ..PenSample::default() });         }         assert_eq!(feed.take_moves().len(), MAX_MOVES, "bounded");-        feed.set_move(PenSample { pressure: 0.5, eraser: false });+        feed.set_move(PenSample { pressure: 0.5, ..PenSample::default() });         feed.set(None);         assert!(feed.take_moves().is_empty(), "a mouse moved");     }@@ -342,11 +395,11 @@ mod tests {         for p in [0.0, 0.8, 0.0] {             feed.set(Some(p));         }-        feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true }));+        feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true, ..PenSample::default() }));         assert_eq!(feed.held(), Some(0.0), "the eraser end has not touched");         assert_eq!(feed.press_within(Duration::ZERO), Some(0.0), "nor pressed");-        feed.set_sample(Some(PenSample { pressure: 0.4, eraser: true }));-        feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true }));+        feed.set_sample(Some(PenSample { pressure: 0.4, eraser: true, ..PenSample::default() }));+        feed.set_sample(Some(PenSample { pressure: 0.0, eraser: true, ..PenSample::default() }));         feed.set(Some(0.0));         assert_eq!((feed.held(), feed.press_within(Duration::ZERO)), (Some(0.0), Some(0.0)), "back to the tip");     }diff --git a/docs/development.md b/docs/development.md--- a/docs/development.md+++ b/docs/development.md@@ -118,7 +118,8 @@ The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (Preferences › Performance › Linux Display Server › X11 (XWayland); X11 (XWayland) When a Pen Is Attached (`x11WithPen`), which opens the window through XWayland only when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and logs `pen found: opening the window through XWayland`; `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way.-- **Pen pressure under X11 and XWayland (#764):** winit 0.30 hands a pen to an X11 window as a mouse, without its pressure. When the window is on X11, XWayland included, the desktop app opens a second X connection (x11rb's `RustConnection`) and reads XInput2 raw events for every device (`apps/vectorcraft/src/tablet.rs`, from PhotoCraft's `crates/tablet`). A device with an `Abs Pressure` valuator is a pen, unless its XInput type is `TOUCHPAD` or `TOUCHSCREEN`. While a pen is the pointer that moved last, its pressure, scaled from the valuator's range to 0..1, reaches the tools that use pressure: the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen. A pen device whose name or XInput type contains `eraser` (in any case) is the pen's eraser end: while the pointer is over the window, the canvas selects the Eraser tool when the eraser end moves, and it selects the tool used before when the tip moves again (`crates/ui-egui/src/stylus.rs`). The X server needs XInput 2.1 or later; otherwise the log has a `tablet pressure:` warning with the reason, and the pen draws at full pressure.+- **Pen pressure under X11 and XWayland (#764):** winit 0.30 hands a pen to an X11 window as a mouse, without its pressure. When the window is on X11, XWayland included, the desktop app opens a second X connection (x11rb's `RustConnection`) and reads XInput2 raw events for every device (`apps/vectorcraft/src/tablet.rs`, from PhotoCraft's `crates/tablet`). A device with an `Abs Pressure` valuator is a pen, unless its XInput type is `TOUCHPAD` or `TOUCHSCREEN`. While a pen is the pointer that moved last, its pressure, scaled from the valuator's range to 0..1, reaches the tools that use pressure: the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen. The reader also reads `Abs Tilt X` and `Abs Tilt Y` (from a pen that has both) in degrees, scaled to ±60° when the valuator's range does not fit in ±90, and `Abs Rotary Z` (an Art Pen's barrel rotation) as a turn with the middle of the range at 0°; the Paintbrush records them for brushes with Tilt, Bearing and Rotation variation. A pen device whose name or XInput type contains `eraser` (in any case) is the pen's eraser end: while the pointer is over the window, the canvas selects the Eraser tool when the eraser end moves, and it selects the tool used before when the tip moves again (`crates/ui-egui/src/stylus.rs`). The X server needs XInput 2.1 or later; otherwise the log has a `tablet pressure:` warning with the reason, and the pen draws at full pressure.+- **Pen tilt and rotation on macOS (#764):** with each tablet event's pressure (#852), `apps/vectorcraft/src/mac_tablet.rs` reads its `tilt`, −1..1 on each axis, scaled to ±60° with y turned round so that it is positive towards the user, and its `rotation`, turned round to run clockwise. A rotation of 0 reads as none, since a pen without a rotating barrel reports 0. The Paintbrush records them as it does under X11.  ## Fonts: craft-fonts (optional build input) diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -457,11 +457,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall   move of a stroke with its own pressure, and the pen's eraser end there selecting the Eraser tool, as on Linux   under X11 and XWayland; not yet tried on a Mac with a tablet. On the web, a press of the pen's eraser end   selects the Eraser tool and a press of the tip brings back the tool before; not yet tried with a pen.-  under X11 and XWayland; not yet tried on a Mac with a tablet. The pen's eraser end on Windows with Windows Ink,-  read from the pen's pointer messages; not yet tried with a pen.+  The pen's eraser end on Windows with Windows Ink, read from the pen's pointer messages; not yet tried with a+  pen. On Linux under X11 and XWayland and on macOS, the Paintbrush records a pen's tilt, bearing and barrel+  rotation, and Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac with a tablet). - **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and-  barrel rotation for brushes (6D Art Pen, #372); the eraser end on Windows and native Wayland.-  barrel rotation for brushes (6D Art Pen, #372); the eraser end on native Wayland and the web.+  barrel rotation on Windows, native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen,+  #372); the eraser end on native Wayland. - **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify.  ## G5 Brushes, symbols and librariesdiff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- a/docs/hardware-parity.md+++ b/docs/hardware-parity.md@@ -13,7 +13,8 @@ documentation (GPU Performance, touch workspace, pen pressure pages); ours from the GPU only to composite, so any GPU that can show a window works, at the cost of GPU-accelerated preview on huge documents. VectorCraft reads pen pressure on Windows, on the web, on macOS, and on Linux under X11 and XWayland, and it reads the pen's eraser end on macOS, on Linux under X11 and XWayland, and on the web when it presses.-reads the pen's eraser end on Windows, on macOS and on Linux under X11 and XWayland.+reads the pen's eraser end on Windows, on macOS and on Linux under X11 and XWayland. It reads the pen's tilt and+barrel rotation on macOS and on Linux under X11 and XWayland.  | Feature | Illustrator | VectorCraft macOS | Windows | Linux | Web | Status / hours | |---|---|---|---|---|---|---|@@ -22,7 +23,7 @@ reads the pen's eraser end on Windows, on macOS and on Linux under X11 and XWayl | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | | Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac |-| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) |+| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | no | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: Windows, native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 4–8 h after the input layer (#372) | | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | no | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial | | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | no | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h |diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -412,7 +412,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |-| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. |+| `pointer_gesture` | `{events:[{kind,x,y,mods?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |@@ -1473,6 +1473,21 @@ already walks Image Trace. Short agent entry points:   longer `newName`. `brush.duplicate` does not check the length. A bristle brush that already has a longer name   keeps it, and `brush.options` without a new name, or with a `newName` that only trims the white space around the   name, does not refuse. Other brush types take a name of any length.+- Pen tilt, bearing and barrel rotation (#764): `path.freehand` and `brush.freehand` take points as+  `[x, y, pressure, tiltX, tiltY, rotation]` or `{x, y, pressure?, tiltX?, tiltY?, rotation?}`, with `null` for what a+  point lacks, and a `pointer_gesture` event takes `tiltX`, `tiltY` and `rotation`, which the Paintbrush records.+  `tiltX` and `tiltY` are degrees −90..90 in document axes, positive leaning right and leaning down the page (one+  without the other is 0); `rotation` is the barrel's rotation in degrees clockwise. The stroke keeps how far the pen+  leaned (`tilt`, 0 upright to 1 lying flat), which way (`bearing`, degrees counter-clockwise from leaning right; a+  pen within 1° of upright takes the bearing before it, or at the start of the stroke its first one) and how far the+  barrel turned (`rotation`, degrees counter-clockwise). A point without a tilt that other points have counts as+  leaning 45°, and one without a rotation as unturned. Calligraphic and Scatter `modes` take `tilt`, `bearing` and+  `rotation`. A Calligraphic value on Tilt goes from the value less the variation with the pen lying flat to the value+  plus the variation upright. Bearing and Rotation add+  the variation times the bearing or the rotation over 180°, and the angle turns on past ±180°. A Scatter value on+  Tilt goes from its first value with the pen lying flat to its second upright, and on Bearing or Rotation from its+  first value at −180° to its second at 180°. A stroke without that input draws the brush's own value (a Scatter+  brush's value half way between the two). - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`,   `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`,   `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph@@ -2449,8 +2464,9 @@ and `dx`, `dy`, `tol` and `radius` in `path.reshape`. A null is read as a key le an `x` without a `y`, or a `y` without an `x`, with "x and y must be given together". `path.freehand`, `path.curvature`, `path.knife`, `path.eraseRegion`, `path.eraseSegments`, `path.smoothRegion`, `path.joinScrub` and `path.blob` refuse a point in `points` whose x or y is not a number, such as `["10", 10]` or a point written as a-string, with "x and y must both be numbers". All of them except `path.curvature` refuse a pen pressure that is neither-a number nor null with `` `pressure` must be a number, not "0.5" ``. `path.close` refuses an `in` that is not `[x, y]`+string, with "x and y must both be numbers". All of them except `path.curvature` refuse a pen pressure, tilt or+rotation that is neither a number nor null with `` `pressure` must be a number, not "0.5" ``, naming the key+(`pressure`, `tiltX`, `tiltY` or `rotation`). `path.close` refuses an `in` that is not `[x, y]` with `` `in` must be [x, y] ``.  These commands refuse a string, a number or another value that is not true or false withdiff --git a/docs/target-app-parity.md b/docs/target-app-parity.md--- a/docs/target-app-parity.md+++ b/docs/target-app-parity.md@@ -856,8 +856,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur     With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke.   - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own.   - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.-  - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.+  - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, and the pen's tilt, bearing and rotation on Windows, native Wayland and the web.     Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush).+  - **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft).     Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value.     Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. Stylus Wheel, Tilt, Bearing and Rotation are not built.     Scatter, Art and Pattern Brush Options have the Tips button for Colorization that the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes, beside Method. It opens Colorization Tips, which shows each method's name and what it does beside a sample of the brush, as OK would make it, painted with that method on a blue stroke; Hue Shift's sample uses the dialog's Key Color. `ui.dialog.set {field: "tips", value: true}` opens it. Screen readers name the button, the popover and each sample.