diff --git a/AGENTS.md b/AGENTS.md index ac0f5b17..1ef7b98c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ crates/plugins L0 Sandboxed WebAssembly plug-ins: wasmi host, ABI v1, limits, crates/audio L0 Sound decoding (WAV, Ogg Vorbis, MP3), pure mixing and resampling, WAV, peaks (ADR 0020) crates/actions L0 Timeline action IR: stop/play/goto, targets, button events, frame scripts (ADR 0024) crates/guide L0 The user guide (docs/guide) as a bundled static HTML book, with its link checks (ADR 0069) +crates/pen-macos L0 macOS pen pressure: AppKit tablet points for the brush (ADR 0096) crates/doc L1 Document model, easing, timeline evaluation, the interactive player, sample scene crates/text L1 Fonts on this computer, shaping and line layout into outlines (ADR 0012) crates/render L2 CPU oracle renderer (coverage accumulation, paints, strokes, PNG) @@ -69,7 +70,9 @@ a new crate needs a row here and in its `LAYERS` table. `doc::evaluate` and keep it pure. 4. **The CPU renderer is the oracle.** Renderer changes come with analytic or golden tests. Any future GPU renderer is tested against it. -5. **No unsafe** (`unsafe_code = "forbid"`). +5. **No unsafe** (`unsafe_code = "forbid"`). The one exception is `crates/pen-macos` (ADR 0096): + it reads macOS pen pressure, because the workspace lint cannot be inherited and then + overridden. `src/` does not call AppKit. Do not add a second exception. 6. **Clean room.** Learn behaviour from public docs and observation only. Never copy proprietary code, presets, shaders or assets. 7. **Verify UI changes visually**: `cargo run -- --screenshot shots/now.png`, then look at the PNG. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9760d1e7..ce2d3450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +* **Raw brush** (ADR 0096, M7). + * **Smoothing Off** is the points you drew. The plain Brush opens there. The centre line is + the samples and the outline is the distance-field contour as line segments, with no cubic. + A command that omits smoothing stays at 50. Built-in presets keep theirs. + * **Thinnest** and **Max** are the widths at no pressure and at a full press (0.01 stage + pixels up to the size you set). A light pen, including the start of the stroke, can be a + hairline. Built-in pressure brushes keep the lightest width they were drawn at. + * The ink you see while the pen is down is one mesh of the samples, and it stays until the + stage picture shows that stroke. Every pointer position in the frame is kept. + * On macOS the stylus pressure is read (winit does not deliver it). Turning **Speed as + pressure** off leaves that pen alone and paints a mouse at full width. Speed is on for the + plain Brush, and the opening dash sets the width of the start. + * The view zooms from 5 % to 25600 %. Ruler steps of a quarter and a half stage pixel stay + readable there. Tilt, texture and scatter (ADR 0093) are unchanged. Object Drawing is + still off in the editor and on for a headless session. * **Sound output polish** (ADR 0095, M5). * The playhead follows the sound being **heard**: the device's latency is taken off and slow frames are dropped rather than drifting (within a frame over an hour of play in tests). diff --git a/Cargo.lock b/Cargo.lock index 31037e81..e05d1ec1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,7 @@ dependencies = [ "anicraft-geom", "anicraft-gpu", "anicraft-guide", + "anicraft-pen-macos", "anicraft-render", "cpal", "craft-update", @@ -344,6 +345,15 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "anicraft-pen-macos" +version = "0.2.1" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "anicraft-plugins" version = "0.2.1" @@ -2813,6 +2823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", + "objc2-exception-helper", ] [[package]] @@ -2986,6 +2997,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + [[package]] name = "objc2-foundation" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 71b04155..c87b13b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,9 @@ cpal = { version = "0.18", optional = true } # objc2 / objc2-app-kit / objc2-foundation are the versions wgpu already brings on macOS. [target.'cfg(target_os = "macos")'.dependencies] fmv-macos-events = "=0.1.0" +# Pen pressure (ADR 0096). winit drops the tablet point; this crate reads it. The shell stays +# free of unsafe. First-party, macOS only. Only src/platform.rs calls it. +anicraft-pen-macos = { path = "crates/pen-macos" } # The system's high-contrast setting (ADR 0064): one registry value. MIT, already in the tree # through craft-update. diff --git a/README.md b/README.md index eb6390d7..f656a1e4 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ The sample animation, exported as an animated GIF by AniCraft's own renderer fro expect them. If you know Adobe Animate or Flash, you already know AniCraft. - **Fast.** A pure Rust engine with a deterministic, analytic anti-aliasing CPU renderer, band-parallel rendering, a tiled stage and a background render worker. No Electron, no webview, no waiting. -- **Robust.** No `unsafe` code (`unsafe_code = "forbid"`), no panics on input, every command fuzzed, and +- **Robust.** No `unsafe` in the app (`unsafe_code = "forbid"`; the one exception is the macOS + pen-pressure monitor, ADR 0096), no panics on input, every command fuzzed, and undo with structural sharing (`Arc`), so history stays instant and memory-proportional. - **Time is a function.** The document is plain data, and the picture at a frame is a pure function, `evaluate(doc, frame)`. Classic tweens with easing presets and shape tweens with contour morphing. diff --git a/crates/engine/src/brush_library.rs b/crates/engine/src/brush_library.rs index ba1bb6d5..7ac8c2ae 100644 --- a/crates/engine/src/brush_library.rs +++ b/crates/engine/src/brush_library.rs @@ -62,8 +62,10 @@ impl Shape { #[serde(default)] pub struct BrushPreset { pub name: String, - /// Diameter (or side), stage units. + /// Diameter (or side) at full pressure, stage units. pub size: f32, + /// Diameter at no pressure, stage units. Never above `size` (ADR 0096). + pub min_size: f32, pub shape: Shape, /// Degrees, clockwise. pub angle: f32, @@ -73,7 +75,7 @@ pub struct BrushPreset { pub smoothing: f32, /// How pen pressure maps before it drives size or opacity. pub curve: PressureCurve, - /// Pressure varies the size (from 10 % to 100 %). + /// Pressure varies the size, from `min_size` at no pressure to `size` at full. pub pressure_size: bool, /// Pressure varies the paint's opacity (the stroke's average, ADR 0068). pub pressure_opacity: bool, @@ -101,6 +103,7 @@ impl Default for BrushPreset { BrushPreset { name: String::new(), size: 10.0, + min_size: 0.01, shape: Shape::Round, angle: 0.0, roundness: 100.0, @@ -165,6 +168,14 @@ pub fn builtin(key: &str) -> Option { "spatter" => BrushPreset { size: 7.0, smoothing: 50.0, spacing: 60.0, scatter: 140.0, ..d }, _ => d, }) + .map(|mut b| { + // Built-ins keep the light-pressure width they were drawn at (a tenth of the size). + // The plain Brush tool sets its own Thinnest (ADR 0096). + if b.pressure_size { + b.min_size = (b.size * 0.1).max(0.01); + } + b + }) } impl BrushPreset { @@ -174,6 +185,7 @@ impl BrushPreset { let c = |v: f32, lo: f32, hi: f32, def: f32| if v.is_finite() { v.clamp(lo, hi) } else { def }; self.name = clean_name(&self.name).unwrap_or_default(); self.size = c(self.size, *crate::cmds_brush::SIZE_RANGE.start(), *crate::cmds_brush::SIZE_RANGE.end(), d.size); + self.min_size = c(self.min_size, *crate::cmds_brush::SIZE_RANGE.start(), *crate::cmds_brush::SIZE_RANGE.end(), d.min_size).min(self.size); self.angle = c(self.angle, -1e6, 1e6, 0.0).rem_euclid(180.0); self.roundness = c(self.roundness, 5.0, 100.0, 100.0); self.smoothing = c(self.smoothing, 0.0, 100.0, d.smoothing); @@ -203,6 +215,7 @@ impl BrushPreset { texture: self.texture / 100.0, scatter: self.scatter / 100.0, tilt: TiltResponse { angle: self.tilt_angle, width: self.tilt_width }, + min_size: self.min_size, } .sanitized() } @@ -228,6 +241,7 @@ impl BrushPreset { let mut b = self.clone(); let r = crate::cmds_brush::ranged; b.size = r(p, "size", crate::cmds_brush::SIZE_RANGE, b.size)?; + b.min_size = r(p, "min_size", crate::cmds_brush::SIZE_RANGE, b.min_size)?.min(b.size); b.shape = match opt_str(p, "shape")? { None => b.shape, Some("round") => Shape::Round, @@ -454,7 +468,8 @@ mod tests { assert_eq!(p.clone().sanitized(), p, "{key} is already in range"); assert!(!builtin_hint(key).is_empty()); } - assert_eq!(BrushPreset::default().shaping(), Shaping::default(), "the plain brush shapes nothing"); + // Thinnest is set (ADR 0096). Taper, texture, scatter and tilt stay off. + assert_eq!(BrushPreset::default().shaping(), Shaping { min_size: 0.01, ..Shaping::default() }); } #[test] @@ -490,7 +505,7 @@ mod tests { // fields read with defaults. let got = BrushLibrary::parse_file(r#"{"format": "anicraft-brushes", "version": 1, "brushes": [7, {"name": ""}, {"name": "Old", "size": -4, "spacing": 2, "curve": [[0.5, 9]]}]}"#).unwrap(); assert_eq!(got.len(), 1); - assert_eq!((got[0].size, got[0].spacing, got[0].smoothing), (0.1, 5.0, 50.0)); + assert_eq!((got[0].size, got[0].spacing, got[0].smoothing), (0.01, 5.0, 50.0)); assert_eq!(got[0].curve.points(), &[[0.0, 1.0], [0.5, 1.0], [1.0, 1.0]]); // Preferences: the library as a list. let lib = BrushLibrary::from_values(serde_json::from_str::>(&serde_json::to_string(&mine).unwrap()).unwrap()); diff --git a/crates/engine/src/brush_library_tests.rs b/crates/engine/src/brush_library_tests.rs index c26054e2..e38dd35b 100644 --- a/crates/engine/src/brush_library_tests.rs +++ b/crates/engine/src/brush_library_tests.rs @@ -120,8 +120,8 @@ fn pressure_curves_map_before_the_size() { area(&shapes(&s)[0]) }; let (lin, soft, firm) = (area_with(json!("linear")), area_with(json!("soft")), area_with(json!("firm"))); - // Linear: factor 0.1 + 0.9 × 0.5 = 0.55 of 20 across 400. - assert!((lin - 400.0 * 11.0).abs() / (400.0 * 11.0) < 0.06, "{lin}"); + // Linear, plain brush: Thinnest 0.01, so half pressure is about half of 20 across 400. + assert!((lin - 400.0 * 10.0).abs() / (400.0 * 10.0) < 0.06, "{lin}"); assert!(soft > lin * 1.15 && firm < lin * 0.85, "{soft} {lin} {firm}"); // A curve of points: flat at 1 paints full size at any pressure. let full = area_with(json!([[0, 1], [1, 1]])); diff --git a/crates/engine/src/cmds_brush.rs b/crates/engine/src/cmds_brush.rs index 0eb593e3..8989642e 100644 --- a/crates/engine/src/cmds_brush.rs +++ b/crates/engine/src/cmds_brush.rs @@ -20,12 +20,12 @@ pub fn specs() -> Vec { c( "draw.brush", "Brush Stroke", - "{points: [[x, y], [x, y, pressure 0..1 (clamped)] or [x, y, pressure, altitude, azimuth] (the pen's tilt in degrees: altitude 90 upright to 0 flat, azimuth the way it leans, clockwise from +x)], ..] (one point paints a dab), preset? (a brush from brush.preset_list, by key or name: its settings are the defaults for the rest), size? (stage units, default 10), shape?: round|square, angle? (degrees), roundness? (5..100 %, default 100), smoothing? (0..100, default 50), curve?: linear|soft|firm|[[pressure, output], ..] (how pressure maps, both 0..1), pressure_size? (default true: pressure varies the size from 10 to 100 %), pressure_opacity? (default false: the stroke's average pressure sets its opacity), taper_start?, taper_end? (0..50 % of the stroke's length), spacing? (0 continuous, or 5..1000 % of the size between dabs), jitter? (0..50 % of the size: a ragged edge), texture? (0..100 %: a paper grain of holes through the paint), scatter? (0..200 % of the size: dabs thrown either side of the line, varied in size), tilt_angle? (the pen's tilt turns the tip: its angle plus the azimuth), tilt_width? (the pen's tilt widens the tip, up to twice as wide flat; also the Paint Brush's width), as_stroke? (default false; true: the Paint Brush, a variable-width stroke in the stroke colour, editable with the Width tool), mode?: normal|fills|behind|selection|inside (Paint Normal covers fills and lines, Paint Fills leaves lines, Paint Behind paints only empty areas, Paint Selection only inside the selected fills, Paint Inside only inside the fill the stroke starts on or the empty area closed off around it; ADR 0080), color? (default: the fill colour, or the stroke colour as a stroke), unit? (stage units per screen point, default 1: smoothing and accuracy follow it), object?}", + "{points: [[x, y], [x, y, pressure 0..1 (clamped)] or [x, y, pressure, altitude, azimuth] (the pen's tilt in degrees: altitude 90 upright to 0 flat, azimuth the way it leans, clockwise from +x)], ..] (one point paints a dab), preset? (a brush from brush.preset_list, by key or name: its settings are the defaults for the rest), size? (stage units at full pressure, default 10), min_size? (stage units at no pressure, default 0.01, never above size), shape?: round|square, angle? (degrees), roundness? (5..100 %, default 100), smoothing? (0..100, default 50), curve?: linear|soft|firm|[[pressure, output], ..] (how pressure maps, both 0..1), pressure_size? (default true: pressure varies the size from min_size to size), pressure_opacity? (default false: the stroke's average pressure sets its opacity), taper_start?, taper_end? (0..50 % of the stroke's length), spacing? (0 continuous, or 5..1000 % of the size between dabs), jitter? (0..50 % of the size: a ragged edge), texture? (0..100 %: a paper grain of holes through the paint), scatter? (0..200 % of the size: dabs thrown either side of the line, varied in size), tilt_angle? (the pen's tilt turns the tip: its angle plus the azimuth), tilt_width? (the pen's tilt widens the tip, up to twice as wide flat; also the Paint Brush's width), as_stroke? (default false; true: the Paint Brush, a variable-width stroke in the stroke colour, editable with the Width tool), mode?: normal|fills|behind|selection|inside (Paint Normal covers fills and lines, Paint Fills leaves lines, Paint Behind paints only empty areas, Paint Selection only inside the selected fills, Paint Inside only inside the fill the stroke starts on or the empty area closed off around it; ADR 0080), color? (default: the fill colour, or the stroke colour as a stroke), unit? (stage units per screen point, default 1: smoothing and accuracy follow it), object?}", true, draw_brush as crate::RunFn, ), c("brush.preset_list", "Brush Presets", "{} the built-in brushes and yours, with their settings", false, preset_list), - c("brush.preset_save", "Save Brush", "{name, from? (a preset to start from; default: the plain brush), size?, shape?, angle?, roundness?, smoothing?, curve?, pressure_size?, pressure_opacity?, taper_start?, taper_end?, spacing?, jitter?, texture?, scatter?, tilt_angle?, tilt_width? (as draw.brush)} saves a brush of your own (replacing yours of that name; kept in your preferences)", false, preset_save), + c("brush.preset_save", "Save Brush", "{name, from? (a preset to start from; default: the plain brush), size?, min_size?, shape?, angle?, roundness?, smoothing?, curve?, pressure_size?, pressure_opacity?, taper_start?, taper_end?, spacing?, jitter?, texture?, scatter?, tilt_angle?, tilt_width? (as draw.brush)} saves a brush of your own (replacing yours of that name; kept in your preferences)", false, preset_save), c("brush.preset_rename", "Rename Brush", "{name, to}", false, preset_rename), c("brush.preset_delete", "Delete Brush", "{name} deletes a brush of your own (asks first: it can't be undone)", false, preset_delete), c("brush.preset_export", "Export Brushes", "{path, names? (default: all yours)} writes your brushes to a brush set file (JSON)", false, preset_export), @@ -34,7 +34,7 @@ pub fn specs() -> Vec { } /// Brush sizes, in stage units. -pub const SIZE_RANGE: std::ops::RangeInclusive = 0.1..=2000.0; +pub const SIZE_RANGE: std::ops::RangeInclusive = 0.01..=2000.0; /// A paint mode (Animate's names). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -240,11 +240,12 @@ fn draw_brush(s: &mut Session, p: &Value) -> Result { // Pressure through the curve (ADR 0068) before it sizes the tip. let pts: Vec = pts.iter().map(|x| Sample { pressure: b.size_pressure(x.pressure), ..*x }).collect(); - // Smoothing and accuracy are screen distances, so a stroke looks the same at any zoom: at 50 - // the centre line may wander 3.3 screen points from the hand, at 0 a third of a point. - // The outline is fitted within a third of a screen point, or a hundredth of the size for - // small brushes (so a small dab is still round). - let (smoothing, tol) = (unit * (0.3 + b.smoothing * 0.06), (unit * 0.35).min(size * 0.01)); + // Smoothing and accuracy are screen distances, so a stroke looks the same at any zoom. + // Smoothing 0 is the points themselves: no cubic through the centre line or the outline + // (ADR 0096). Just above 0 the centre line stays within 0.3 of a screen point, and at 100 + // it may wander 6.3. The outline, above 0, is fitted within about a third of a screen + // point, or a hundredth of the size for small brushes. + let (smoothing, tol) = if b.smoothing <= 1e-8 { (0.0, 0.0) } else { (unit * (0.3 + b.smoothing * 0.06), (unit * 0.35).min(size * 0.01)) }; if as_stroke { return paint_stroke(s, &pts, &b, smoothing, tol, color, object); } @@ -358,7 +359,7 @@ const PROFILE_TOLERANCE: f32 = 0.015; /// stroke colour, its width profile following the pressure and tapers, so the Width tool edits /// it afterwards. A stroke of one width is a plain line and merges like the Pencil's. fn paint_stroke(s: &mut Session, pts: &[Sample], b: &BrushPreset, smoothing: f32, tol: f32, color: Rgba, object: bool) -> Result { - let (path, factors) = centre_stroke(pts, &b.shaping(), smoothing, tol * 0.25, PROFILE_SAMPLES); + let (path, factors) = centre_stroke(pts, &b.shaping(), smoothing, tol * 0.25, PROFILE_SAMPLES, b.size); if !path.cmds.iter().any(|c| matches!(c, PathCmd::CubicTo(..) | PathCmd::LineTo(..))) { return Err(EngineError::Param("the stroke is too short to draw a line".into())); } diff --git a/crates/geom/src/brush.rs b/crates/geom/src/brush.rs index 354f8d5e..d06a6d08 100644 --- a/crates/geom/src/brush.rs +++ b/crates/geom/src/brush.rs @@ -18,6 +18,8 @@ //! 4. **Outline.** The zero level is traced by marching squares (`crate::trace`), with linear //! interpolation along cell edges (accurate well below a cell, since the field is a true //! distance), mapped back to the stage and fitted with cubic curves again, keeping corners. +//! Smoothing 0 (ADR 0096) skips both fits: the centre line is the samples, and the contour +//! is written as line segments. //! //! The result is closed loops (outer boundaries and holes) filled with the even-odd rule. The //! grid has at most `MAX_CELLS` cells, so cost and memory are bounded for any input. @@ -133,10 +135,25 @@ pub fn tilted(angle: f32, lean: Vec2, response: TiltResponse) -> (f32, f32) { (turned, if response.width { 1.0 + TILT_WIDTH * l } else { 1.0 }) } -/// The tip's size factor at `pressure`: from `MIN_PRESSURE_SIZE` at none to 1 at full. -pub fn size_factor(pressure: f32) -> f32 { +/// Width at no pressure when a brush sets no Thinnest of its own, in stage units (ADR 0096). +/// The same floor as the size range. Zero would drop the tip out of the sweep. +pub const DEFAULT_MIN_SIZE: f32 = 0.01; +/// Smallest size factor that still paints. +const FACTOR_FLOOR: f32 = 1e-6; + +/// The tip's size factor at `pressure`. `min_size` is the diameter at no pressure and `size` +/// the diameter at full pressure, both in stage units. The factor is that diameter divided by +/// `size` (the tip is built at full size). `min_size` above `size` reads as `size`. A missing +/// size reads as 1, and the factor never hits 0. +/// +/// A `min_size` of 0 keeps the historical floor ([`MIN_PRESSURE_SIZE`]): light pressure is a +/// tenth of the size. The Brush tool sets a real Thinnest instead (ADR 0096). +pub fn size_factor(pressure: f32, min_size: f32, size: f32) -> f32 { let p = if pressure.is_finite() { pressure.clamp(0.0, 1.0) } else { 1.0 }; - MIN_PRESSURE_SIZE + (1.0 - MIN_PRESSURE_SIZE) * p + let size = if size.is_finite() && size > 0.0 { size } else { 1.0 }; + let min = if min_size.is_finite() && min_size > 0.0 { min_size.min(size) } else { size * MIN_PRESSURE_SIZE }; + let floor = (min / size).clamp(FACTOR_FLOOR, 1.0); + floor + (1.0 - floor) * p } /// How a stroke is shaped beyond its tip (ADR 0068): tapered ends, separate dabs and a ragged @@ -161,6 +178,9 @@ pub struct Shaping { pub scatter: f32, /// What the pen's tilt shapes. pub tilt: TiltResponse, + /// Diameter at no pressure, stage units. `0` keeps the historical tenth-of-the-size floor + /// (ADR 0068). A set value is Thinnest (ADR 0096), and it never goes above the tip's size. + pub min_size: f32, } /// Most dabs one stroke stamps (the spacing widens past it). @@ -183,9 +203,15 @@ impl Shaping { texture: c(self.texture, 1.0), scatter: c(self.scatter, 2.0), tilt: self.tilt, + min_size: if self.min_size.is_finite() && self.min_size > 0.0 { self.min_size } else { 0.0 }, } } + /// The tip's size factor at `pressure` for a tip of diameter `size`. + pub fn width_factor(self, pressure: f32, size: f32) -> f32 { + size_factor(pressure, self.min_size, size) + } + /// The dab spacing a stroke stamps at (scatter needs dabs). fn dab_spacing(&self) -> f32 { if self.spacing <= 0.0 && self.scatter > 0.0 { SCATTER_SPACING } else { self.spacing } @@ -212,13 +238,13 @@ impl Shaping { /// curve fit's tolerance (stage units); `flatness` how closely the points follow the fitted /// curves. One distinct sample gives one point (a dab). pub fn centreline(samples: &[Sample], smoothing: f32, flatness: f32) -> Vec<(Vec2, f32)> { - centreline_shaped(samples, smoothing, flatness, &Shaping::default()) + centreline_shaped(samples, smoothing, flatness, &Shaping::default(), 1.0) } /// The centre line with the shaping's tapers applied to the size factors (and the tilt's -/// widening, when the shaping says tilt shapes the width). -pub fn centreline_shaped(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Shaping) -> Vec<(Vec2, f32)> { - centre_points(samples, smoothing, flatness, shaping).into_iter().map(|p| (p.at, p.f)).collect() +/// widening, when the shaping says tilt shapes the width). `size` is the tip's full diameter. +pub fn centreline_shaped(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Shaping, size: f32) -> Vec<(Vec2, f32)> { + centre_points(samples, smoothing, flatness, shaping, size).into_iter().map(|p| (p.at, p.f)).collect() } /// A point of the centre line: where, the tip's size factor, and the pen's lean there (a vector @@ -236,7 +262,7 @@ impl Pt { } } -fn centre_points(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Shaping) -> Vec { +fn centre_points(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Shaping, size: f32) -> Vec { let shaping = shaping.sanitized(); let mut pts: Vec = Vec::with_capacity(samples.len().min(MAX_SAMPLES)); let mut press: Vec = Vec::with_capacity(pts.capacity()); @@ -246,13 +272,13 @@ fn centre_points(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Sh if pts.last().is_some_and(|l| l.distance(s.at) <= 1e-4) { // A held pointer: keep the latest pressure and tilt. if let (Some(p), Some(l)) = (press.last_mut(), leans.last_mut()) { - *p = size_factor(s.pressure); + *p = shaping.width_factor(s.pressure, size); *l = lean_of(s); } continue; } pts.push(s.at); - press.push(size_factor(s.pressure)); + press.push(shaping.width_factor(s.pressure, size)); leans.push(lean_of(s)); } // The tilt's widening multiplies the size factor (ADR 0093). @@ -262,10 +288,16 @@ fn centre_points(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Sh ([p], [f, ..], [l, ..]) => return vec![Pt { at: *p, f: widen(*f, *l), lean: *l }], _ => {} } - let tol = if smoothing.is_finite() && smoothing > 0.01 { smoothing } else { 0.01 }; - let flat = if flatness.is_finite() && flatness > 1e-3 { flatness } else { 0.05 }; - let fitted = fit_curve(&pts, tol, tol * 2.0, false); - let line: Vec = fitted.flatten(&Affine::IDENTITY, flat).into_iter().next().map(|p| p.points).unwrap_or_else(|| pts.clone()); + // Smoothing 0 is the samples. Anything higher is a cubic fit. Only a missing tolerance + // falls back to a small fit (ADR 0096). + let line: Vec = if smoothing.is_finite() && smoothing <= 1e-6 { + pts.clone() + } else { + let tol = if smoothing.is_finite() && smoothing > 1e-6 { smoothing } else { 0.01 }; + let flat = if flatness.is_finite() && flatness > 1e-6 { flatness } else { 0.05 }; + let fitted = fit_curve(&pts, tol, tol * 2.0, false); + fitted.flatten(&Affine::IDENTITY, flat).into_iter().next().map(|p| p.points).unwrap_or_else(|| pts.clone()) + }; // Pressure by fraction of length: the fit keeps the stroke's ends and its corners, so the // same fraction is about the same place. let along = |v: &[Vec2]| -> Vec { @@ -281,8 +313,11 @@ fn centre_points(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Sh acc.iter_mut().for_each(|a| *a /= total); acc }; - // A taper needs points along the line to follow it (a straight drag flattens to two). - let line = if shaping.taper_start > 0.0 || shaping.taper_end > 0.0 { subdivide(&line, 96) } else { line }; + // A straight drag flattens to two points. A taper, or a pressure change, needs points + // along the line or the thin part is never stamped and the stroke stays one width. + let varies = press.windows(2).any(|w| (w[0] - w[1]).abs() > 1e-3); + let line = if shaping.taper_start > 0.0 || shaping.taper_end > 0.0 || varies { subdivide(&line, 96) } else { line }; + let floor = shaping.width_factor(0.0, size); let (src, dst) = (along(&pts), along(&line)); let mut j = 0; line.iter() @@ -296,7 +331,13 @@ fn centre_points(samples: &[Sample], smoothing: f32, flatness: f32, shaping: &Sh let (l0, l1) = (leans.get(j).copied().unwrap_or(Vec2::ZERO), leans.get(j + 1).copied().or(leans.get(j).copied()).unwrap_or(Vec2::ZERO)); let t = if s1 - s0 > 1e-9 { ((u - s0) / (s1 - s0)).clamp(0.0, 1.0) } else { 0.0 }; let lean = l0.lerp(l1, t); - Pt { at: *p, f: widen((f0 + (f1 - f0) * t) * shaping.taper(u), lean), lean } + // A set Thinnest holds the taper there, so a point does not open a hole. Unset + // keeps the taper's own 5 % (ADR 0068). + let mut f = (f0 + (f1 - f0) * t) * shaping.taper(u); + if shaping.min_size > 0.0 { + f = f.max(floor); + } + Pt { at: *p, f: widen(f, lean), lean } }) .collect() } @@ -321,14 +362,24 @@ fn subdivide(line: &[Vec2], n: usize) -> Vec { /// The Paint Brush's line (ADR 0068): the smoothed centre line as an open path, and the tip's /// size factor along it as `(fraction of length, factor)` at `max` (2..) evenly spaced places. /// An empty path when the samples don't make a line (one point, or none finite). -pub fn centre_stroke(samples: &[Sample], shaping: &Shaping, smoothing: f32, flatness: f32, max: usize) -> (Path, Vec<(f32, f32)>) { - let line = centreline_shaped(samples, smoothing, flatness, shaping); +pub fn centre_stroke(samples: &[Sample], shaping: &Shaping, smoothing: f32, flatness: f32, max: usize, size: f32) -> (Path, Vec<(f32, f32)>) { + let line = centreline_shaped(samples, smoothing, flatness, shaping, size); if line.len() < 2 { return (Path::new(), Vec::new()); } let pts: Vec = line.iter().map(|(p, _)| *p).collect(); - let tol = if smoothing.is_finite() && smoothing > 0.01 { smoothing.min(flatness.max(0.05) * 4.0) } else { 0.05 }; - let path = fit_curve(&pts, tol, tol * 2.0, false); + // Smoothing 0 is the polyline of the samples. A higher smoothing fits it. + let path = if smoothing.is_finite() && smoothing <= 1e-6 { + let mut path = Path::new(); + if let Some((first, rest)) = pts.split_first() { + path.cmds.push(PathCmd::MoveTo(*first)); + path.cmds.extend(rest.iter().copied().map(PathCmd::LineTo)); + } + path + } else { + let tol = if smoothing.is_finite() && smoothing > 0.01 { smoothing.min(flatness.max(0.05) * 4.0) } else { 0.05 }; + fit_curve(&pts, tol, tol * 2.0, false) + }; let mut along = Vec::with_capacity(line.len()); let mut d = 0.0; for w in line.windows(2) { @@ -400,8 +451,17 @@ pub fn outline(samples: &[Sample], tip: &Tip, smoothing: f32, tolerance: f32) -> /// The outline with tapers, dabs, a ragged edge, texture, scatter or tilt (ADR 0068, 0093). pub fn outline_shaped(samples: &[Sample], tip: &Tip, shaping: &Shaping, smoothing: f32, tolerance: f32) -> Path { let shaping = shaping.sanitized(); - let tol = if tolerance.is_finite() && tolerance > 1e-3 { tolerance } else { 0.25 }; - let mut line = centre_points(samples, smoothing, tol * 0.25, &shaping); + // Tolerance 0 is the raw contour (smoothing 0). A missing tolerance stays the old fit. + let raw_edge = tolerance.is_finite() && tolerance <= 1e-6; + let tol = if raw_edge { + 0.0 + } else if tolerance.is_finite() && tolerance > 1e-3 { + tolerance + } else { + 0.25 + }; + let centre_smooth = if raw_edge { 0.0 } else { smoothing }; + let mut line = centre_points(samples, centre_smooth, if raw_edge { 0.0 } else { tol * 0.25 }, &shaping, tip.size); let spacing = shaping.dab_spacing(); let dabbed = spacing > 0.0 && line.len() > 1; if dabbed { @@ -565,6 +625,11 @@ pub fn outline_shaped(samples: &[Sample], tip: &Tip, shaping: &Shaping, smoothin let mut out = Path::new(); for lp in trace::contours(&field, w, h).into_iter().take(MAX_LOOPS) { let stage: Vec = lp.into_iter().map(to_stage).collect(); + if raw_edge { + // The contour of the sweep, with no cubic through it. + push_loop(&mut out, &stage); + continue; + } let part = fit_curve(&stage, tol, window, true); if part.cmds.iter().any(|c| matches!(c, PathCmd::CubicTo(..) | PathCmd::LineTo(..))) { out.cmds.extend(part.cmds); @@ -573,6 +638,26 @@ pub fn outline_shaped(samples: &[Sample], tip: &Tip, shaping: &Shaping, smoothin out } +/// One closed contour as line segments. A loop with no edge is left out. +fn push_loop(out: &mut Path, pts: &[Vec2]) { + let mut pts = pts.iter().copied().filter(|p| p.is_finite()); + let Some(first) = pts.next() else { return }; + let start = out.cmds.len(); + out.cmds.push(PathCmd::MoveTo(first)); + let mut prev = first; + for p in pts { + if p.distance(prev) > 1e-4 { + out.cmds.push(PathCmd::LineTo(p)); + prev = p; + } + } + if out.cmds.len() > start + 1 { + out.cmds.push(PathCmd::Close); + } else { + out.cmds.truncate(start); + } +} + /// Most loops one outline keeps (a fine paper grain on a long stroke opens many holes). pub const MAX_LOOPS: usize = 20_000; /// The paper grain's size, in tip radii. @@ -793,6 +878,25 @@ mod tests { assert!(inside(150.0, 5.0) && !inside(150.0, 6.2)); } + #[test] + fn smoothing_zero_follows_the_samples() { + // An L. Smoothing 0 keeps every sample; the outline is the sweep of those points. + let s = [ + Sample::new(Vec2::new(0.0, 0.0), 1.0), + Sample::new(Vec2::new(30.0, 0.0), 1.0), + Sample::new(Vec2::new(30.0, 40.0), 1.0), + ]; + let sh = Shaping { min_size: DEFAULT_MIN_SIZE, ..Shaping::default() }; + let line = centreline_shaped(&s, 0.0, 0.0, &sh, 8.0); + for p in &s { + assert!(line.iter().any(|(q, _)| q.distance(p.at) < 1e-3), "lost {:?}", p.at); + } + let outline = outline_shaped(&s, &Tip::round(8.0), &sh, 0.0, 0.0); + assert!(outline.contains(Vec2::new(30.0, 0.0), &Affine::IDENTITY, FillRule::EvenOdd), "the corner is ink"); + assert!(!outline.contains(Vec2::new(18.0, 18.0), &Affine::IDENTITY, FillRule::EvenOdd), "no curve cuts the corner"); + assert!(outline.cmds.iter().all(|c| !matches!(c, PathCmd::CubicTo(..))), "no cubic"); + } + #[test] fn smoothing_reduces_anchors() { // A shaky hand: a straight drag with jitter. @@ -890,7 +994,7 @@ mod tests { #[test] fn the_paint_brush_line_has_a_profile() { let s = line(Vec2::ZERO, Vec2::new(200.0, 0.0), 40, |t| t); - let (path, prof) = centre_stroke(&s, &Shaping::default(), 1.0, 0.1, 16); + let (path, prof) = centre_stroke(&s, &Shaping::default(), 1.0, 0.1, 16, 20.0); assert!(!path.is_empty() && prof.len() == 16); assert_eq!(prof.first().map(|p| p.0), Some(0.0)); assert_eq!(prof.last().map(|p| p.0), Some(1.0)); @@ -900,7 +1004,7 @@ mod tests { assert!(prof.windows(2).all(|w| w[1].1 >= w[0].1 - 1e-4), "rising"); let b = path.bounds(); assert!((b.width() - 200.0).abs() < 1.0 && b.height() < 1.0); - assert!(centre_stroke(&s[..1], &Shaping::default(), 1.0, 0.1, 16).0.is_empty(), "a dab is no line"); + assert!(centre_stroke(&s[..1], &Shaping::default(), 1.0, 0.1, 16, 20.0).0.is_empty(), "a dab is no line"); } /// The bounds of the curve itself (`Path::bounds` includes control points). @@ -997,8 +1101,8 @@ mod tests { #[test] fn hostile_input_is_empty_or_bounded() { - let wild = Shaping { taper_start: f32::NAN, taper_end: 1e9, spacing: -3.0, jitter: f32::INFINITY, texture: f32::NAN, scatter: 1e9, tilt: TiltResponse::default() }; - assert_eq!(wild.sanitized(), Shaping { taper_start: 0.0, taper_end: 0.5, spacing: 0.0, jitter: 0.0, texture: 0.0, scatter: 2.0, tilt: TiltResponse::default() }); + let wild = Shaping { taper_start: f32::NAN, taper_end: 1e9, spacing: -3.0, jitter: f32::INFINITY, texture: f32::NAN, scatter: 1e9, tilt: TiltResponse::default(), min_size: f32::NAN }; + assert_eq!(wild.sanitized(), Shaping { taper_start: 0.0, taper_end: 0.5, spacing: 0.0, jitter: 0.0, texture: 0.0, scatter: 2.0, tilt: TiltResponse::default(), min_size: 0.0 }); let _ = outline_shaped(&line(Vec2::ZERO, Vec2::new(10.0, 0.0), 3, |_| 1.0), &Tip::round(5.0), &Shaping { spacing: 1e-9, jitter: 0.5, ..wild }, 1.0, 1.0); assert!(outline(&[], &Tip::round(10.0), 1.0, 1.0).is_empty()); let nan = [Sample::new(Vec2::new(f32::NAN, 0.0), f32::NAN)]; diff --git a/crates/pen-macos/Cargo.toml b/crates/pen-macos/Cargo.toml new file mode 100644 index 00000000..818c2106 --- /dev/null +++ b/crates/pen-macos/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "anicraft-pen-macos" +description = "macOS pen pressure for the AniCraft brush: AppKit tablet points, so the shell stays free of unsafe" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6.5", features = ["exception"] } +block2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSResponder", "block2"] } + +# Not the workspace lints: those forbid unsafe, and this crate is the one AppKit monitor +# (the block returns the same event it was given). Clippy matches the workspace. +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" diff --git a/crates/pen-macos/src/lib.rs b/crates/pen-macos/src/lib.rs new file mode 100644 index 00000000..8bdee9dc --- /dev/null +++ b/crates/pen-macos/src/lib.rs @@ -0,0 +1,219 @@ +//! Pen pressure on macOS (ADR 0096). +//! +//! winit 0.30 delivers a mouse event for a stylus and drops the tablet point, and egui never +//! sees trackpad force either. This crate reads that one tablet point and hands back +//! `0.0..=1.0`. The shell stays free of `unsafe`. A mouse is not a pen: turning Speed as +//! pressure off leaves a real pen alone and paints a mouse at full width. +//! +//! Hover is not a sample. A tablet point with the tip up and no pressure is ignored, so a pen +//! resting nearby does not thin a mouse stroke. A tip-down tablet point from the last 30 ms +//! wins over the mouse event the system sends beside it (that copy is often pressure 1, which +//! is the fat dot at the start of a stroke). + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; + +/// How long a tablet point still belongs to the mouse event the system sends with it. +pub const PEN_WINDOW_SECS: f64 = 0.03; + +/// `NSEventTypeTabletPoint`. +const TABLET_POINT: u64 = 23; +/// `NSEventSubtypeTabletPoint` on a mouse event. The same number means something else on other events. +const SUBTYPE_TABLET_POINT: i16 = 1; + +static HAVE: AtomicBool = AtomicBool::new(false); +static BITS: AtomicU32 = AtomicU32::new(0); +static AT: AtomicU64 = AtomicU64::new(0); + +fn mono() -> f64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() +} + +/// Remember a tip-down tablet point. `at` is seconds on the same clock as the read. +fn remember(pressure: f32, at: f64) { + if !(pressure.is_finite() && at.is_finite()) { + return; + } + BITS.store(pressure.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed); + AT.store(at.to_bits(), Ordering::Relaxed); + HAVE.store(true, Ordering::Relaxed); +} + +/// The tablet point remembered just now, for the frame that draws it. +fn live() -> Option { + pressure_as_of(mono()) +} + +/// The tablet point at `now`, when it is still inside [`PEN_WINDOW_SECS`]. +pub fn pressure_as_of(now: f64) -> Option { + if !HAVE.load(Ordering::Relaxed) { + return None; + } + let at = f64::from_bits(AT.load(Ordering::Relaxed)); + if !(at.is_finite() && now.is_finite()) || now + 1e-3 < at || now - at > PEN_WINDOW_SECS { + return None; + } + let p = f32::from_bits(BITS.load(Ordering::Relaxed)); + if p.is_finite() { Some(p.clamp(0.0, 1.0)) } else { None } +} + +/// Pressure from one AppKit event, or nothing when it is a mouse, a hover, or not finite. +/// +/// `kind` is `NSEventType`, `subtype` is `NSEventSubtype`, `tip_down` is the pen-tip button +/// (tablet points only; pass false for a mouse event). +pub fn classify(kind: u64, subtype: i16, tip_down: bool, pressure: f32) -> Option { + if !pressure.is_finite() { + return None; + } + let p = pressure.clamp(0.0, 1.0); + if kind == TABLET_POINT { + // Hover reports pressure 0 with the tip up. A light touch is either the tip button or + // a pressure above none, including a driver that leaves the button mask empty. + if tip_down || p > 0.0 { Some(p) } else { None } + } else if is_mouse(kind) && subtype == SUBTYPE_TABLET_POINT { + Some(p) + } else { + None + } +} + +fn is_mouse(kind: u64) -> bool { + // Left/right/other mouse down, up, and dragged. Not moved, not entered. + matches!(kind, 1 | 2 | 3 | 4 | 6 | 7 | 25 | 26 | 27) +} + +/// Start listening. Safe to call more than once. No-op off the main thread and off macOS. +pub fn install() { + #[cfg(target_os = "macos")] + imp::install(); +} + +/// The pen's pressure for the event being handled, when it is a stylus. +pub fn current() -> Option { + #[cfg(target_os = "macos")] + { + imp::current() + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{classify, live, remember}; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSEvent, NSEventButtonMask, NSEventMask, NSEventType}; + use std::ptr::NonNull; + use std::sync::atomic::{AtomicBool, Ordering}; + + pub fn install() { + static STARTED: AtomicBool = AtomicBool::new(false); + if STARTED.swap(true, Ordering::Relaxed) { + return; + } + if MainThreadMarker::new().is_none() { + STARTED.store(false, Ordering::Relaxed); + return; + } + let block = block2::RcBlock::new(|event: NonNull| -> *mut NSEvent { + // A throw from a tablet field must not leave this callback. Rust cannot unwind through it. + let _ = objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + // SAFETY: AppKit calls the monitor with the event it is dispatching, alive for this call. + let ev = unsafe { event.as_ref() }; + note(ev); + })); + event.as_ptr() + }); + let mask = NSEventMask::LeftMouseDown + | NSEventMask::LeftMouseUp + | NSEventMask::LeftMouseDragged + | NSEventMask::RightMouseDown + | NSEventMask::RightMouseUp + | NSEventMask::RightMouseDragged + | NSEventMask::OtherMouseDown + | NSEventMask::OtherMouseUp + | NSEventMask::OtherMouseDragged + | NSEventMask::TabletPoint; + // SAFETY: the block returns the same event pointer it was given, so delivery is unchanged. + // AppKit removes the monitor if the token is dropped, so both live for the process. + let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(mask, &block) }; + std::mem::forget(block); + std::mem::forget(token); + } + + pub fn current() -> Option { + install(); + if let Some(mtm) = MainThreadMarker::new() + && let Some(event) = NSApplication::sharedApplication(mtm).currentEvent() + { + note(&event); + // The tablet point wins over the mouse event sent with it, which often says pressure 1. + if let Some(p) = live() { + return Some(p); + } + return event_pressure(&event); + } + live() + } + + fn note(event: &NSEvent) { + if event.r#type() == NSEventType::TabletPoint + && let Some(p) = event_pressure(event) + { + remember(p, super::mono()); + } + } + + /// Pressure for a tablet point or a mouse event that carries one. + /// + /// `pressure` and `subtype` raise if the event is a key, a scroll, or anything else. + /// That exception cannot cross into Rust, so the read is limited to the event kinds + /// that have those fields, and a throw becomes "no pen" instead of aborting the process. + fn event_pressure(event: &NSEvent) -> Option { + let read = objc2::exception::catch(std::panic::AssertUnwindSafe(|| { + let ty = event.r#type(); + let kind = ty.0 as u64; + if ty != NSEventType::TabletPoint && !super::is_mouse(kind) { + return None; + } + let subtype = event.subtype().0; + let tip = ty == NSEventType::TabletPoint && event.buttonMask().contains(NSEventButtonMask::PenTip); + classify(kind, subtype, tip, event.pressure()) + })); + read.unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_pen_is_a_tablet_point_and_a_mouse_is_not() { + assert_eq!(classify(TABLET_POINT, 0, true, 0.0), Some(0.0), "tip down, no force: thinnest"); + assert_eq!(classify(TABLET_POINT, 0, true, 0.12), Some(0.12)); + assert_eq!(classify(TABLET_POINT, 0, false, 0.2), Some(0.2), "a driver that omits the button"); + assert_eq!(classify(TABLET_POINT, 0, false, 0.0), None, "hover"); + assert_eq!(classify(TABLET_POINT, 0, true, f32::NAN), None); + assert_eq!(classify(TABLET_POINT, 0, true, 2.0), Some(1.0)); + // Left mouse dragged, carrying a tablet point. + assert_eq!(classify(6, SUBTYPE_TABLET_POINT, false, 0.05), Some(0.05)); + // The same drag as an ordinary mouse, even if something left a pressure in the event. + assert_eq!(classify(6, 0, false, 0.4), None); + assert_eq!(classify(1, 0, false, 1.0), None, "mouse down is full width, not a pen"); + assert_eq!(classify(10, SUBTYPE_TABLET_POINT, false, 0.5), None, "a key is not a pen"); + } + + #[test] + fn a_tablet_point_covers_the_mouse_copy_and_then_expires() { + remember(0.12, 10.0); + assert_eq!(pressure_as_of(10.02), Some(0.12)); + assert_eq!(pressure_as_of(10.0 + PEN_WINDOW_SECS + 0.01), None); + remember(0.0, 20.0); + assert_eq!(pressure_as_of(20.0), Some(0.0), "a light touch is a real sample"); + } +} diff --git a/docs/adr/0096-raw-brush.md b/docs/adr/0096-raw-brush.md new file mode 100644 index 00000000..bfa4c776 --- /dev/null +++ b/docs/adr/0096-raw-brush.md @@ -0,0 +1,115 @@ +# ADR 0096: Raw brush + +* Status: proposed +* Relates to: ADR 0033 (brush tool), ADR 0031 (merge drawing), ADR 0068 (brush library, + pressure curves, Paint Brush), ADR 0093 (tilt, texture, scatter) + +## Context + +This is the brush a person who drew in Flash CS6 all day would recognize. The reasons are +theirs. + +You drew with the Brush. A stroke was a filled shape in the fill colour. Where it touched the +same colour it became one shape, so the eraser and the selection tool could clean the drawing +the way you clean ink. Object Drawing was the switch for when a stroke had to stay its own +object. Leaving it on for every stroke turned a drawing into a pile of pieces you could not +edit. The editor already starts with it off (ADR 0031). This record does not change that. A +script with no window still makes one element per stroke, because there is no stage there to +merge onto by eye. + +Smoothing all the way off was a look, not a broken setting. The wobble of the hand stayed in +the vectors. You could see where the pen hesitated. People who inked that way put the slider +at 0 and left it there. A fit of even a third of a screen point sands that off: the line comes +back smoother than the hand, and you cannot correct a drawing you cannot see. The ink on the +screen while the pen is down has to be the ink that is there after you lift. + +A tablet's lightest touch was a hairline. A hard press was the size you had set. Ten percent +of a 20 px brush is still a 2 px sausage, which is not a light touch. The first sample was +often a fat dot, because the system also sent a mouse event at full pressure with the +pen-down. The workaround was to start the stroke already moving. That is not a pen. Speed can +stand in for a mouse. It must not replace a pen, and turning it off must not freeze every +stroke at full width: a light pen is still light. + +The curve is how you mean the pressure. Linear is a medium touch at a medium width. Firm +stays near the hairline until you press. Marker and dotted were always one width. Pressure +was never their point. + +Nothing here is taken from Flash, from Wick Editor, or from any other program. No code, no +presets, no brushes. The stroke is still the distance-field outline (ADR 0033). Tilt, paper +grain and spatter (ADR 0093) stay. This is the ink under them. Tilt is still not read from +the desktop. winit 0.30 does not deliver a macOS stylus pressure either, and a light line +cannot be drawn without that one number. + +## Decision + +1. **Smoothing 0 is the points you drew.** At 0 the centre line is the samples and the outline + is the distance-field contour written as line segments. There is no cubic. Just above 0 the + old fit returns: the centre line within a fraction of a screen point of the hand, and at + 100 it may wander several screen points. A straight run whose pressure changes is sampled + along its length, so a thin point is stamped instead of being lost between the two ends. + The plain Brush tool opens at 0. A command that omits `smoothing` stays at 50, so a script + that never asked for raw ink does not suddenly get it. Built-in presets keep the smoothing + they were drawn with. + +2. **You see the ink you draw.** Every pointer position in the frame is kept. One position + per frame cuts the corners out of a fast stroke, and the line comes back smoother than the + hand. While the pen is down the preview is one filled mesh of those samples. Separate + shapes flashed where they met. The mesh stays up until the stage picture shows that + stroke, or the document has moved on, so the ink does not vanish and come back as a + different line. At smoothing 0 the stored point is the latest sample. Higher smoothing + still steadies the recent hand, newest heaviest. + +3. **Pressure runs from Thinnest to Max.** Thinnest (`min_size`, default 0.01 stage pixels) + is the width at no pressure. Max (`size`) is the width at full pressure. Both are in + Properties and on `draw.brush`, and Thinnest never goes above Max. The factor never hits + 0, so the tip does not drop out of the sweep. A shaping that sets no Thinnest keeps the + old floor, a tenth of the size (ADR 0068), so geometry that was drawn that way stays + valid. Built-in presets that use pressure pin their lightest width at a tenth of their + size for the same reason. The plain Brush is the one that opens at 0.01. A taper still + comes down to 5 % unless a Thinnest is set, and then it stops at the width of no pressure + so a point does not open a hole. Marker and Dotted stay one width (`pressure_size: false`). + The Curve is unchanged. + +4. **A real pen is the pen. Speed is only the mouse.** On macOS the pressure is the tablet + point AppKit already delivers. winit drops it, so `pen-macos` listens for it. The shell + does not call AppKit and has no `unsafe`. The crate has two `unsafe` sites, each with a + safety comment: the monitor block borrows the event AppKit is dispatching, and it + registers that monitor. Reading pressure or the button mask on the wrong event kind aborts + the process, so those reads run only on mouse and tablet events, inside + `objc2::exception::catch`. The workspace lint forbids `unsafe` and cannot be inherited and + then overridden, so this crate sets its own lint (`unsafe_code = allow`) and does not take + the workspace lints. That is the whole exception. It reads pressure only. Tilt stays where + ADR 0093 left it. + A tip-down sample in the last 30 ms wins over the mouse event sent with the pen, once, so + the start can be thin. After that the pen's own pressure is kept for the stroke. Turning + **Speed as pressure** off leaves the pen alone. With it off, a mouse paints full width. + With it on (the plain Brush's default), the opening dash's own speed is the width of that + start, down to Thinnest, not a half-width dot. At smoothing 0 a later sample is that + sample's own speed. Higher smoothing still eases it, so one jerk does not show. + +5. **The pen can be smaller than a pixel, and you can zoom in far enough to use it.** Brush + and eraser size, and Thinnest, go down to 0.01 stage pixels. Widths are stage pixels, the + same at any zoom. The view runs from 5 % to 25600 %. The zoom menu lists those stops; the + wheel and the zoom keys already followed the clamp. Ruler steps of 0.25 and 0.5 stage + pixels keep the ruler readable that far in. It stays finite so stage coordinates remain + exact. + +6. **Strokes still join unless you ask them not to.** Unchanged from ADR 0031. Object + Drawing (J) keeps a new stroke as its own object. A headless session still starts with + Object Drawing on. + +## Consequences + +* The live mesh is aliased. The picture that replaces it is anti-aliased, so the edge can + soften by a fraction of a cell when the tile arrives. At smoothing 0 the vectors are the + samples. At a higher smoothing the mesh can still differ from the fitted outline, which is + the steadying you asked for. +* A long hairline can coarsen. The distance-field grid will not grow past its cell budget, and + a sample thinner than a cell is stamped at least that wide so it is not skipped. Zoom in to + place it; the stored width is still the stage width. +* A driver that reports a hover as a tiny pressure with the tip up is treated as a touch. + Hover with the tip up and pressure 0 is ignored. +* A saved brush that omitted `min_size` reads as 0.01 after it is checked. One that stored a + smoothing value is unchanged. Built-in presets are unchanged in look. +* Desktop tilt is still unread (ADR 0093). Opacity along a stroke is still one average + (ADR 0068). diff --git a/docs/adr/README.md b/docs/adr/README.md index 8a35fed1..2e4d7537 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -23,3 +23,4 @@ has to renumber because another PR merged first. | Number | Pull request | Topic | |---|---|---| | 0037 | #38 (outside contribution) | Interface languages | +| 0096 | #336 (outside contribution) | Raw brush | diff --git a/docs/architecture.md b/docs/architecture.md index 9c8a78b9..0991b208 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,6 +33,7 @@ app/ workspace root; the root package is the desktop app plugins L0 WebAssembly plug-in sandbox (wasmi): ABI v1, limits, manifest, registry (ADR 0017) audio L0 Sound decoding (WAV, Ogg Vorbis, MP3), pure mixing/resampling, WAV, waveform peaks actions L0 Timeline action IR: stop/play/goto, instance targets, button events, frame scripts + pen-macos L0 macOS pen pressure for the brush (AppKit tablet points, ADR 0096) doc L1 Document model + timeline evaluation (keyframes, tweens, easing, symbols) + player render L2 CPU oracle rasterizer, paints/gradients, compositor, PNG encode engine L3 Session (document + history + selection + playhead), command registry, file format @@ -48,6 +49,7 @@ app/ workspace root; the root package is the desktop app | L0 | `anicraft-plugins` | wasmi, serde_json (knows nothing of documents; the engine is its host) | | L0 | `anicraft-audio` | std, lewton, nanomp3 | | L0 | `anicraft-actions` | serde (knows nothing of documents: targets are name paths) | +| L0 | `anicraft-pen-macos` | objc2 AppKit on macOS only; only `src/platform.rs` calls it (ADR 0096) | | L1 | `anicraft-doc` | geom, audio, actions | | L2 | `anicraft-render` | geom, doc, rayon, png | | L3 | `anicraft-engine` | geom, doc, render, serde_json | diff --git a/docs/guide/drawing.md b/docs/guide/drawing.md index ee33bd5e..486f4c94 100644 --- a/docs/guide/drawing.md +++ b/docs/guide/drawing.md @@ -137,9 +137,11 @@ takes back the last point, and **Alt**-click on a line or a selected shape adds ### The Brush The **Brush** (**B**) paints filled shapes in the fill colour: the shape is exactly the area -the tip sweeps. A click paints a single dab. The options set the **Size** (or press **[** and -**]**), **Shape** (Round or Square), **Angle** and **Roundness** for calligraphic tips, -**Smoothing** to steady a shaky hand, and the **Mode**: +the tip sweeps. A click paints a single dab. The options set **Smoothing** first (the plain +Brush opens at **Off**, which keeps every point of the stroke), **Thinnest** (the width at no +pressure, 0.01 stage pixels unless you set another) and **Max** (the width at a full press; +**[** and **]** change Max), **Shape** (Round or Square), **Angle** and **Roundness** for +calligraphic tips, and the **Mode**: * **Paint Normal** covers lines and fills. * **Paint Fills** leaves lines alone. @@ -149,8 +151,12 @@ the tip sweeps. A click paints a single dab. The options set the **Size** (or pr * **Paint Inside** paints only inside the fill where your stroke starts, however far you go past its edge. Started on empty stage inside an outline, it fills only that empty area. -The last three never cover lines. With -a pen tablet, pressure sets the width, from 10 % to the full size. +The last three never cover lines. Pressure runs from Thinnest to Max. On macOS a stylus +uses its own pressure, including a light start, whether or not **Speed as pressure** is on. +Speed is the mouse: it is on for the plain Brush, a fast stroke goes down to Thinnest, and +turning it off paints a mouse at full width. The curve still decides how fast a light press +thickens. Marker and Dotted stay one width. Zoom runs from 5 % to 25600 %, so a hairline can +be placed. Widths are stage pixels, the same at any zoom. **Texture and scatter.** **Texture** lets a paper grain show through the paint, like charcoal on rough paper; the grain stays put on the stage, so strokes laid over each other share it. diff --git a/docs/guide/reference/commands.md b/docs/guide/reference/commands.md index c68a55f3..94a87d20 100644 --- a/docs/guide/reference/commands.md +++ b/docs/guide/reference/commands.md @@ -598,11 +598,11 @@ These commands have no menu item: the panels, the stage and the timeline use the - **Movie Explorer** `explorer.tree` Parameters: `{scope?: document (default) | scene, show?: {text?, symbols?, scripts?, media?, frames? (layers and keyframes)} (each default true), definitions?: bool (the Library's symbols; default true), expanded?: [row keys] (default: the top rows), all?: bool (everything open), filter?: text (rows whose names hold it, and the rows above them), offset?, limit? (default 1000, at most 10000)} → {rows: [{key, depth, kind, type?, name, extra, frame?, children, open, go: find.goto params}], total, truncated}` - **Brush Stroke** `draw.brush` · changes the document - Parameters: `{points: [[x, y], [x, y, pressure 0..1 (clamped)] or [x, y, pressure, altitude, azimuth] (the pen's tilt in degrees: altitude 90 upright to 0 flat, azimuth the way it leans, clockwise from +x)], ..] (one point paints a dab), preset? (a brush from brush.preset_list, by key or name: its settings are the defaults for the rest), size? (stage units, default 10), shape?: round|square, angle? (degrees), roundness? (5..100 %, default 100), smoothing? (0..100, default 50), curve?: linear|soft|firm|[[pressure, output], ..] (how pressure maps, both 0..1), pressure_size? (default true: pressure varies the size from 10 to 100 %), pressure_opacity? (default false: the stroke's average pressure sets its opacity), taper_start?, taper_end? (0..50 % of the stroke's length), spacing? (0 continuous, or 5..1000 % of the size between dabs), jitter? (0..50 % of the size: a ragged edge), texture? (0..100 %: a paper grain of holes through the paint), scatter? (0..200 % of the size: dabs thrown either side of the line, varied in size), tilt_angle? (the pen's tilt turns the tip: its angle plus the azimuth), tilt_width? (the pen's tilt widens the tip, up to twice as wide flat; also the Paint Brush's width), as_stroke? (default false; true: the Paint Brush, a variable-width stroke in the stroke colour, editable with the Width tool), mode?: normal|fills|behind|selection|inside (Paint Normal covers fills and lines, Paint Fills leaves lines, Paint Behind paints only empty areas, Paint Selection only inside the selected fills, Paint Inside only inside the fill the stroke starts on or the empty area closed off around it; ADR 0080), color? (default: the fill colour, or the stroke colour as a stroke), unit? (stage units per screen point, default 1: smoothing and accuracy follow it), object?}` + Parameters: `{points: [[x, y], [x, y, pressure 0..1 (clamped)] or [x, y, pressure, altitude, azimuth] (the pen's tilt in degrees: altitude 90 upright to 0 flat, azimuth the way it leans, clockwise from +x)], ..] (one point paints a dab), preset? (a brush from brush.preset_list, by key or name: its settings are the defaults for the rest), size? (stage units at full pressure, default 10), min_size? (stage units at no pressure, default 0.01, never above size), shape?: round|square, angle? (degrees), roundness? (5..100 %, default 100), smoothing? (0..100, default 50), curve?: linear|soft|firm|[[pressure, output], ..] (how pressure maps, both 0..1), pressure_size? (default true: pressure varies the size from min_size to size), pressure_opacity? (default false: the stroke's average pressure sets its opacity), taper_start?, taper_end? (0..50 % of the stroke's length), spacing? (0 continuous, or 5..1000 % of the size between dabs), jitter? (0..50 % of the size: a ragged edge), texture? (0..100 %: a paper grain of holes through the paint), scatter? (0..200 % of the size: dabs thrown either side of the line, varied in size), tilt_angle? (the pen's tilt turns the tip: its angle plus the azimuth), tilt_width? (the pen's tilt widens the tip, up to twice as wide flat; also the Paint Brush's width), as_stroke? (default false; true: the Paint Brush, a variable-width stroke in the stroke colour, editable with the Width tool), mode?: normal|fills|behind|selection|inside (Paint Normal covers fills and lines, Paint Fills leaves lines, Paint Behind paints only empty areas, Paint Selection only inside the selected fills, Paint Inside only inside the fill the stroke starts on or the empty area closed off around it; ADR 0080), color? (default: the fill colour, or the stroke colour as a stroke), unit? (stage units per screen point, default 1: smoothing and accuracy follow it), object?}` - **Brush Presets** `brush.preset_list` Parameters: `{} the built-in brushes and yours, with their settings` - **Save Brush** `brush.preset_save` - Parameters: `{name, from? (a preset to start from; default: the plain brush), size?, shape?, angle?, roundness?, smoothing?, curve?, pressure_size?, pressure_opacity?, taper_start?, taper_end?, spacing?, jitter?, texture?, scatter?, tilt_angle?, tilt_width? (as draw.brush)} saves a brush of your own (replacing yours of that name; kept in your preferences)` + Parameters: `{name, from? (a preset to start from; default: the plain brush), size?, min_size?, shape?, angle?, roundness?, smoothing?, curve?, pressure_size?, pressure_opacity?, taper_start?, taper_end?, spacing?, jitter?, texture?, scatter?, tilt_angle?, tilt_width? (as draw.brush)} saves a brush of your own (replacing yours of that name; kept in your preferences)` - **Rename Brush** `brush.preset_rename` Parameters: `{name, to}` - **Delete Brush** `brush.preset_delete` diff --git a/docs/guide/workspace.md b/docs/guide/workspace.md index dca9e266..7d2820b6 100644 --- a/docs/guide/workspace.md +++ b/docs/guide/workspace.md @@ -164,7 +164,7 @@ lets you change them (see [Preferences, shortcuts and accessibility](preferences | Zoom out | **View › Zoom Out** (**Ctrl+-**), or **Alt**-click with the Zoom tool | | Fit the whole stage in the window | **View › Fit in Window** (**Ctrl+0**, or **Ctrl+2**) | | See the stage at actual size | **View › 100%** (**Ctrl+1**) | -| Jump to a set zoom | the zoom menu at the right of the edit bar: Fit in window, 25 % to 800 % | +| Jump to a set zoom | the zoom menu at the right of the edit bar: Fit in window, 25 % to 25600 % | ### The pasteboard diff --git a/docs/legal/compliance.md b/docs/legal/compliance.md index b44984c1..03d4d234 100644 --- a/docs/legal/compliance.md +++ b/docs/legal/compliance.md @@ -69,6 +69,23 @@ two Apple Event handlers (open documents, quit) and an observer, and returns pat main-thread inbox. It has no network, file or process access, and no build script. Read any new version in full before it comes in. +### macOS pen pressure (2026-11-14, ADR 0096) + +No new crate from crates.io. `crates/pen-macos` is first-party (MIT OR Apache-2.0), built only +for macOS, and only `src/platform.rs` calls it. It uses `objc2` 0.6 with the `exception` +feature, `block2` 0.6 and `objc2-app-kit` 0.3.2 (default features off), which were already in +the tree through wgpu and `fmv-macos-events`. `objc2-exception-helper` (Zlib OR Apache-2.0 OR +MIT) comes in with that feature. That licence is already allowed. No new licence. + +The workspace forbids `unsafe` and that lint cannot be inherited and then overridden, so this +crate sets its own (`unsafe_code = allow`) and does not take the workspace lints. There are +two `unsafe` sites, each with a safety comment: the event-monitor block borrows the event +AppKit is dispatching, and the call that registers the monitor. Both the block and the +monitor token are kept for the process. Pressure and the button mask are read only on mouse +and tablet events, inside `objc2::exception::catch`, because reading them on another event +kind aborts the process. The shell has no `unsafe`. The crate reads pressure only. It does +not read tilt (ADR 0093). + ### The user guide (2026-11-11, ADR 0069) New crates, from crates.io: `pulldown-cmark` 0.13 (MIT) with `pulldown-cmark-escape` (MIT), diff --git a/docs/requirements.md b/docs/requirements.md index 1ab07e9a..f9b81192 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -87,7 +87,8 @@ Priority: **P0** = M0–M2 skeleton must have, **P1** = parity core, **P2** = lo ## 3. Constraints and rules (inherited from the Craft family) -1. Rust only; no unsafe (`unsafe_code = "forbid"` workspace-wide). +1. Rust only; no unsafe (`unsafe_code = "forbid"` on the workspace). `crates/pen-macos` is the + one exception (ADR 0096): it reads macOS pen pressure. The shell does not contain that code. 2. Everything a user can do is a **command** with an id, label, menu path, shortcut, params schema and tests. UI, CLI, JSON-lines and MCP all dispatch by id. 3. The UI is thin: no document logic in `ui`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 722af8cf..dc60631d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,7 +11,7 @@ milestone ends with a green tree: build, tests, clippy, a `--screenshot` check a | Architecture | M0 skeleton in place: geom → doc → render → engine → cli → shell, layering enforced by `cargo test` (`src/architecture_tests.rs`: lower layers only, no UI crates below the shell, and the AGENTS.md map must match). | | Rendering | CPU oracle with analytic AA, non-zero/even-odd, solid + linear/radial gradients (with reflect/repeat, focal points, elliptical radials and linear RGB, ADR 0074), round-join strokes. **Mask clipping**: the display list carries nested clips; a clip is the anti-aliased union of its mask fills (strokes and opacity ignored), intersected with any enclosing clip, computed once per render band; an empty mask hides everything under it. **Soft masks** (ADR 0070): a mask layer's mask type is Shape (coverage), Alpha (its artwork's opacity as drawn: gradients, faded shapes, strokes, bitmaps) or Luminance (brightness × opacity, SVG's coefficients), in the CPU oracle (analytic tests), the GPU stage (parity: mean 0.00006, worst 1 step of 255), SVG export (`` with `mask-type`), Lottie (alpha and luma track mattes) and the HTML canvas; Layer Properties › Mask type. **Group alpha**: a symbol instance below full opacity is a group in the display list; each render band composites the group's run of ops into its own buffer (nested groups stack) and blends it once at the group's alpha, so overlaps don't show through; SVG export writes ``. **Blend modes** (Animate's 14, on movie clip and button instances): an instance with one is an isolated group that blends at its close with the W3C separable formulas on premultiplied colour (Alpha and Erase keep or clear the backdrop); SVG gets CSS `mix-blend-mode`, Lottie the layer `bm`. **Bitmaps**: `Paint::Bitmap` fills (once or tiled) with bilinear sampling in premultiplied space, colour effects per sample; PNG/JPEG import to stage (a rectangle of the image's size); stored in the document as PNG (base64 in `.anicraft`), decoded once on first use; SVG export embeds each image once as a pattern. **Filters**: a filtered group is drawn on its own (by the same renderer, recursively, so nested filters work) into a buffer over its artwork grown by the filters' reach, filtered (box-blur passes; shadow and glow from the blurred, offset opacity; **bevel and gradient bevel** from the difference of the blurred opacity offset towards and away from the light, **gradient glow** from the glow's intensity, both through a colour ramp whose rest stop is pinned transparent, inner/outer/full and knockout, ADR 0025; colour matrix), and composited back as one image in its group, so alpha and blend apply; clips from inside the symbol apply before filtering and the instance's own clip after. Filters apply in list order and may be switched off (ADR 0091): `evaluate` leaves switched-off ones out, so every renderer and export draws them as absent. **GPU stage, first slice** (M3, ADR 0019; `--features gpu-stage`, off by default): Preferences › Stage › Renderer *GPU (preview)* draws the stage with wgpu compute from the oracle's own prepared fills (tile binning with per-row backdrops on the CPU, exact area coverage and src-over per pixel on the GPU): solid and gradient fills and strokes, non-zero and even-odd; masks, group opacity, blend modes, filters and bitmaps fall back to the CPU per tile, and the status bar says so. Parity with the oracle: max 1/255, mean < 0.0001/255 on the sample, stress and analytic scenes (tests skip without an adapter). 4K with 1,000 shapes: 8.8–9.0 ms vs the CPU's 17.4–17.8 ms. **Second slice** (ADR 0029): on the window's wgpu device the stage tiles are drawn straight into textures egui shows (no readback; a headless device with readback when the window is on OpenGL); **masks** (nested, united artwork), **group opacity** and **all 14 blend modes** on the GPU, same parity (max 1/255, mean ≤ 0.0011/255); 4K: 1,000 shapes in 2.1–2.4 ms, 10k shapes in 10.2 ms as stage tiles, masked scenes 7× the CPU. **Third slice** (ADR 0032): **bitmap fills** (textures uploaded once per image and cached under a 256 MB budget, sampled with the oracle's own bilinear filter, colour effect and repeat rules: byte-identical) and **all seven filters** (each filtered symbol drawn into a layer of its own and filtered by compute passes that run the oracle's resolved steps: max 1/255, mean ≤ 0.007/255) on the GPU; the shaders now build with Direct3D's FXC, so DX12 windows get the GPU stage too (parity suite green on Vulkan and DX12); software adapters are refused (the CPU is faster), the start-up self-test compares with the oracle. **The GPU is the default stage renderer**: the shell's `gpu-stage` feature is on by default, Preferences › Stage › Renderer defaults to *Graphics card (GPU)*, and the CPU draws whenever there is no usable card, a tile is past the GPU's limits (more than 16 images, filters nested deeper than 4) or anything fails. 4K: the filtered sample in 3.9 ms (CPU 31.5), 200 bitmap fills in 4.4 ms (CPU 37.2). **Fourth slice** (ADR 0048): **tile binning on the GPU** for heavy frames without masks or groups (count, prefix sum, scatter and tile-list passes, no atomics, no readback; the CPU binning stays the reference and does the rest; one tile format for both: an integer backdrop per tile and steps on the tiles' left edges): on Vulkan, 10k shapes at 4K in one texture 21.7 → 4.7 ms, 50k shapes 122 → 22.5 ms, the 50k-shape 4K stage 75 → 37 ms (Direct3D 12 keeps CPU binning: slower there in stage tiles); and **hairlines** for outline mode in both renderers (coverage by distance, one edge per segment): onion outlines on the 2,000-item stress scene as fast as tinted (GPU stage 73 → 10 ms, CPU stage 34–39 → 17 ms at 1080p). **Mid-session failures** (ADR 0092): a broken GPU stage hands the stage to the CPU at once with a status-bar notice and a log line, and is retried with a bounded backoff; driver errors on the window's device are logged instead of panicking (the #255 `Surface::configure` panic), and a lost window device autosaves and reopens the window on a safer way. Not yet: sharing a filtered layer across a frame's tiles, more than 16 images per tile, GPU binning inside masks and groups, flattening and stroking on the GPU, buffer reuse and fewer dispatches across tiles, hairlines for sub-pixel strokes, GPU exports, a GPU runner in CI; the CPU stays the oracle and the export path. | | Timeline | **Frame selection**: click, Shift-click or drag across frames and layers; F5/Shift+F5, F6/F7 and Shift+F6 (keys, icon buttons and right-click menu) act on the whole selection; with no frame selection F5 extends every layer with frames at or after the playhead, and F5/F6/F7 move the playhead on as in Animate (F6/F7 on a keyframe key the next frame; `all_layers`/`advance` headless); drag selected keyframes left or right to move them, and up or down onto other layers (the source keeps empty frames, as in Animate) (with a drop outline; one undo step; keys they land on are replaced, and spans fill the frames left behind); removing or clearing selected frames that hold artwork asks first. **Cut/Copy/Paste Frames** (Ctrl+Alt+X/C/V, and plain Ctrl+X/C/V after clicking the timeline): own clipboard, copies cut exactly at the selection edges (mid-tween state kept), paste inserts like Animate, several layers paste onto the layers below (missing ones are created), fresh element ids, symbols travel with them. **Span length by dragging its end** (resize cursor, live outline with the length in frames and seconds; later frames move with it; a motion tween's keys stretch in proportion, ADR 0083). Side dock tab strips never run under the panel menu (names shrink with an ellipsis, then the strip scrolls). Headless: `layers`/`count` on the frame commands, `timeline.move_keyframes`, `timeline.cut_frames/copy_frames/paste_frames`. Layers (show/lock/outline), **timeline polish** (ADR 0061): a per-layer **onion skin toggle** column (Alt-click solos; left-out layers aren't onion-skinned; Edit Multiple Frames unaffected), the colour square toggles **View as Outlines** in the layer colour **on the stage only** (exports, Test Movie and the player draw the artwork as it is), the **colour chip** opens the layer colour (swatches, hex, More…), **onion frames of the parent timeline** dimmed around a symbol edited in place, **Layer Height** 100/200/300 %, **Layer Properties** (double-click the layer icon; name, show/lock, type, outline colour, outlines, onion, height; one undo step), **Frame View** (Tiny/Small/Normal/Medium/Large, Short rows) with the frame zoom in the timeline's bottom bar (the header fits at the default window size, tested); headless `layer.set_onion/set_color/set_height/set_properties/properties`; XFL `heightMultiplier`. Not yet: folder onion toggles, onion of deeper parent timelines, multi-layer Layer Properties and layer transparency, frame thumbnails (Preview). Keyframes, blank keyframes, insert/remove frames, classic tweens with **ease presets and custom eases** (ADR 0081: Classic Ease with a strength and Quad, Cubic, Quart, Quint, Sine, Back, Circ, Bounce and Elastic In/Out/In Out; custom eases of up to 64 Bézier points in the Custom Ease window, x kept monotonic, overshoot allowed, keyboard operable, **shape tweens** (morph between any two shapes: segments made cubic, subpaths paired by size and grown from or shrunk to a point when counts differ, segment counts equalized by subdivision, windings matched and start points aligned; amber spans; classic ⇄ shape keeps the easing; **shape hints** (ADR 0023): Modify › Shape › Add Shape Hint (Ctrl+Shift+H) adds lettered markers `a`–`z` that the artist drags onto an edge on the first and last keyframe (rose until placed, amber placed on the first, mint on the last; right-click removes), the morph cuts both outlines at the hinted points and matches the pieces in order by length; `tween.add_hint/move_hint/remove_hint/clear_hints/hints`; not yet: View › Show Shape Hints, hints inside symbols edited in place), auto-key when editing inside a tween, frame labels, symbol nesting: graphics keep step with their timeline (loop, play once, single frame, first frame; set in Properties), movie clips loop their own timeline from when the instance appears (continuous across keyframes holding the same instance), buttons show their Up frame, looping playback. **Onion skin and Edit Multiple Frames** (ADR 0047): onion markers on the frame ruler (drag the start and end brackets; Always Show Markers, Anchor Markers, Onion 2 / Onion 5 / Onion All, also on a right-click on the ruler), frames before and after the playhead tinted in their own colours (theme blue and green, changeable in the onion menu, Reset) and fading with distance, Outlines, Keyframes Only, locked layers left out as in Animate, each layer's onion frames under its own artwork and over the layers below; options saved in the preferences; the stage's frame cache keys every look apart. **Edit Multiple Frames** shows every keyframe within the markers at full strength and selects (click, Shift-click, marquee, Select All) and moves (drag, arrow keys) artwork across them, one undo step, motion tween paths moving with their objects. Headless: `frames.artwork`, `frames.edit_multiple`. **Edit Multiple Frames, complete** (ADR 0051): Cut, Copy, Paste (current frame), Paste in Place (back keyframe by keyframe, or a one-frame copy onto every keyframe between the markers), Clear, Align (Ctrl+Alt+1…6, To Stage), Rotate 90° and Flip, and **Free Transform** of the selection on every keyframe about one shared pivot (one box, one undo step per drag); raw shapes moved, transformed or pasted merge again on their own keyframe; **onion skin and Edit Multiple Frames inside a symbol edited in place**, on its own timeline and markers. Headless: `frames.transform`, `frames.align`, `frames.delete`, `frames.copy`, `frames.cut`, `frames.paste`, `frames.merge`, `merge` on `frames.edit_multiple`. Not yet: per-layer onion toggles, Duplicate/Distribute/Arrange and recolouring across keyframes, scaling a motion tween's object on several frames, a draggable pivot, onion frames of the timeline around an open symbol. **Mask layers**: right-click a layer › Mask turns it into a mask over the layer below (Masked adds more layers to it); masked layers are indented under a stencil icon; the mask applies on the stage and in every export alike, and while the mask layer is unlocked its edge shows in the layer colour so it can be picked and reshaped; masked-away parts cannot be picked; layers moved in under a mask join it, moved out leave it, and a mask moves with its whole group; masks inside symbols nest. Headless: `layer.toggle_mask`, `layer.set_type`. **Scenes** (ADR 0011): the movie is an ordered list of scenes, each its own timeline, edited one at a time (scene menu on the edit bar, Insert › Scene, Modify › Scene, Shift+PageUp/PageDown); add, duplicate (fresh ids), rename, reorder and remove (asks when the scene has artwork; undoable); PNG-sequence and GIF exports play every scene in order. **Layer folders**: nestable folders (depth ≤ 16) holding layers and masks through `Layer.parent`, one invariant (`Timeline::repair_masks`: a child sits below its parent inside the parent's unbroken run; broken links fall back to the nearest valid ancestor); hiding or locking a folder hides or locks its contents (rendering, picking, drawing); folders open and close on the timeline (view state); layers move in and out with Up/Down, a folder moves with its contents; deleting a folder deletes its contents (asks first). **Motion tweens** (ADR 0016): a span's object animated property by property (x, y, scale, colour, filters, and instance filters tween; XFL custom and per-property eases import exactly; `ease.*` headless) + curve preview, rotation, skew, alpha, tint), each property its own curve of property keyframes with a per-segment ease (any preset, saved ease or custom ease of several segments, ADR 0081; overshoot allowed); auto-key turns any edit of the object inside the span into property keys (every command, through the session), F6 keys every property, splits and copies keep the motion; sky-blue spans with property-key diamonds; **Motion Editor** tab beside the timeline: property list with values at the playhead, curve graph with draggable keys and Bézier handles, one undo step per drag. **Retiming** (ADR 0083): keys drag sideways in time (snapped to frames, never past a neighbour), Shift-click picks keys across properties, an All lane picks a frame's keys, Left/Right move picked keys with the curve focused; dragging a span's end stretches a motion tween's keys in proportion (Shift keeps them); a click picks a whole motion span and dragging (or a selection of whole tween spans) moves it at its length, Alt-drag copies; **Split Motion** / **Join Motion** (split then join is exact). Headless: `motion.move_keys`, `timeline.resize_span`, `timeline.move_spans`, `motion.split`, `motion.join`. Not yet: joining two independent motion paths, retiming from a span's left edge. **Motion paths** (ADR 0030): a motion tween may carry a cubic path its object travels by arc length, timed by the X/Y curves (a straight path reproduces them exactly, overshoot included); shown on the stage in the motion colour with a dot per frame when the object or span is selected; Selection drags the path to bend it and its ends to move them, Subselection drags anchors and handles; moving the object reshapes the path; Orient to path and Straighten in Properties; splits cut the path. **Path points** (ADR 0038): with Subselection, double-click the path to add a point (shape and motion unchanged), Shift-click to select several anchors, Delete to remove them (the path refits through the rest; the ends stay), right-click for smooth/corner and Straighten; the Pen's Alt-click adds a point to a motion path or a selected shape; Ctrl-drag (⌘-drag) the path or its object, or edit Properties › Path start, to move the whole path with its timing exact. **Anchors pinned to frames** (ADR 0049): moving the object at a frame pins a path point there (a new anchor where it was put; after the last position key the path carries on), and the object passes every pinned point exactly on its frame, the X/Y eases timing it between pins; pinned points are diamonds with their frame number on the stage, blue keys in the Motion Editor's X/Y (dragging one moves its point) and holed diamonds on the timeline; Subselection's right-click pins to the playhead's frame or unpins, Properties › Frame has Pin Here / Unpin, F6 pins where the object is; point edits keep pins on their anchors; files without pins evaluate exactly as before. Headless: `timeline.create_motion_tween`, `motion.*` (`motion.set_path/clear_path/orient/add_point/delete_points/convert_point/move_path/pin_point/unpin_point`; `motion.info` reports `pins`). Not yet: retiming a pin by dragging its key in time, two pins on one anchor, XFL motion path keyframes, a marquee over path anchors, nudging or transforming a whole path, retiming keys, rescaling keys with the span, XFL motion-tween import. **Bones and IK, first slice** (ADR 0043): the Bone tool (M) drags from one symbol instance to another to join them with a bone on an armature layer (chains join end to start: a bone from a chained instance always starts at its joint; branches allowed, up to 256 bones); bones show as wedges in the layer colour; the Selection tool drags a bone (the grabbed point follows) or a posed instance to pose by cyclic coordinate descent (joint limits, pinned bones, one undo step per drag) and the root to move it all; poses are joint angles keyed per frame and tweened with the span's easing; Properties › Bone (angle, constrain min/max, pin, remove); `bone.*` headless; every export sees the pose. **Shape armatures** (ADR 0071): the Bone tool draws bones inside a shape (it moves to an armature layer as an object, outside merge drawing); its points are bound to the nearest bones automatically (weights by distance to each bone, up to four, summing to 1) and bend by linear blend skinning (handles with their anchors, strokes keep their width, the rest pose exact); the **Bind tool** (Shift+M) shows the points (squares, triangles where shared) and binds them by click, Shift/Ctrl-click or box; the same IK, poses and tweens; `bone.add_in_shape`, `bone.bind`, `bone.info {points}`. **Painted weights and reshaping** (ADR 0090): the Bind tool's **Paint Weights** brush (Add, Subtract, Smooth; size with `[`/`]`, strength; soft falloff) paints a bone's share onto the points under it, shown live as a heat map (blue none to red all) on the outline and points; weights stay normalised per point (the other bones share the rest, a point's only bone gives way to its nearest other bones), painted points are hand-bound, one undo step per stroke (1.15 ms for 32 dabs over 20,000 points); a focused point's weights as numbers in Properties › Bone (keyboard); Subselection edits a bound shape's outline at the rest pose (move, add, delete, smooth/corner) keeping the binding: moved points keep their weights, new points blend their neighbours', deleted points drop theirs, the rest pose exact; `bone.paint_weights`, `bone.set_weight`. **Springs, joint translation and members in place** (ADR 0084): joints slide along X/Y of what they start on, freely or within pixel limits, as part of each pose (IK slides only where turning can't reach); per-bone Spring and Damping swing bones after the motion and settle, evaluated as a pure replay of up to six seconds of motion (seeking equals playing bit for bit; 0.33 ms a frame for 16 sprung bones), shown on the stage, in Test Movie and every export, switched off per armature for posing; a picked bone's instance takes colour effect, blending and filters in place on every pose; `bone.set_translation`, `bone.set_spring`, `bone.enable_springs`. Not yet: runtime armatures, XFL import, mixing instances and shapes in one armature (proposed in ADR 0084 and 0090), reshaping at a posed frame, gravity and wind. **Asset Warp** (ADR 0079): the **Asset Warp tool** (Shift+Q) pins a shape or a bitmap (the first pin covers it with a warp mesh: a grid over its bounds, 2–48 cells along the longer side, Properties › Asset Warp); dragging a pin bends the artwork around the other pins by rigid moving least squares (Schaefer et al. 2006) at the mesh's vertices, live, one undo step per drag; Delete removes a pin; pins are added to every keyframe holding the artwork and their poses tween with classic tweens and easing; shapes bend through the mesh (straight edges exactly, curves split), bitmaps are drawn through its triangles by the CPU oracle (bilinear) and the GPU stage takes the same prepared fill (parity exact); the rest pose is the drawing bit for bit; every raster export, SVG (a bent bitmap baked as an image), Lottie and HTML (a bent bitmap reported) go through `evaluate`; `warp.add`, `warp.pin_add`, `warp.pin_move`, `warp.pin_remove`, `warp.remove`, `warp.info`. Not yet: as-rigid-as-possible solving on a mesh that follows the outline, symbols and text, binding warp pins to bones, a GPU textured-triangle path, bent bitmaps in HTML and Lottie, XFL import of warped assets. eases saved in the document and offered everywhere; copy/paste of an ease; classic tweens eased per property group: position, **Camera layer** (ADR 0027): the camera button in the timeline header or the Camera tool (C) adds a Camera layer on top; its frame shows on the stage in orange; drag to pan, turn with the round handle, wheel to zoom (one undo step per drag); Properties › Camera (position, zoom, rotation, tint, reset); keyframes and classic or motion tweens animate it; layer Depth gives pan parallax; View › Preview Through Camera shows the movie's picture (artwork tools pause there); hiding the camera layer turns it off; every export, Test Movie and the GPU stage see it. Headless: `camera.add/remove/set/info`, `layer.set_depth`. **Camera, second slice** (ADR 0082): layer depth in a pinhole perspective (deeper layers move and zoom less, draw behind nearer ones; depth 0 bit-exact), depth keyed over time (linear between keys), **Window › Layer Depth** (layers by depth, drag nearer or farther, key, attach), **Attach Layer to Camera** (timeline toggle; HUDs stay on screen), camera colour effects (Brightness, Tint, Advanced), and the Selection tool picking and moving artwork while previewing through the camera; headless `layer.set_depth {key, frame}`, `layer.remove_depth_key`, `layer.set_attached`, `layer.depths`, `element.pick {view: camera}`. Not yet: other tools while previewing, depth inside symbols, depth keys moving with inserted frames, XFL camera and layer-depth import (no public format description). **Motion guides**: guided layers (children of a guide through `Layer.parent`) have their classic tweens follow the guide's first path by arc length, from the point nearest each start keyframe to the point nearest its end (`geom::walk::PathWalk`), optionally orienting to the path; guide artwork is listed for picking and editing, drawn on the stage as a line, and never rendered or exported. | -| Drawing | Selection (click, shift-add, marquee, drag-move, arrow nudge; **across layers**, ADR 0044: a selection holds artwork on several visible, unlocked layers, the timeline marks them, Properties shows mixed values), **Free Transform (Q)**: scale from corner/edge handles (Shift proportional, Alt from centre), rotate outside corners (Shift snaps 15°), move; one undo step per drag. Rectangle (corner radius), oval, PolyStar, line (45° snap), hand, zoom. **Pen (P)**: click for corners, drag for smooth handles (Alt breaks them), close on the first point, Enter/Esc/last-point click finishes, Backspace takes back a point; one undo step per path. **Pencil (Shift+Y)**: Smooth (Schneider curve fitting with corner detection), Straighten and Ink modes, smoothing relative to the screen. **Subselection (A)**: anchors and handles on selected shapes, drag points/handles (anchors bring their handles; one undo step per drag), point marquee, double-click an edge to add a point, Delete removes points, right-click Make Smooth/Corner; closed shapes' start point and closing edge edit like any other. **Cut/Copy/Paste in Center/Paste in Place** (the clipboard carries symbols across documents). **Rotate 90° CW/CCW, Flip H/V. Align** left/centre/right/top/middle/bottom (Ctrl+Alt+1…6; a single element aligns to the stage) and distribute. Convert to symbol / break apart; arrange; duplicate. Headless: `draw.path`, `draw.freehand`, `path.move_points/add_point/delete_points/convert_point`. **Paint Bucket (K)**: click a fill to recolour it, or inside an outline (one shape or several) to fill the enclosed area; gap size None/Small/Medium/Large closes openings; the fill is traced and curve-fitted into a new shape placed under its lines; open areas say so instead of flooding. **Ink Bottle (S)**: gives the clicked shape the current stroke. Headless: `paint.bucket`, `paint.ink`. Tool options show first in Properties while a drawing tool is active. **Text (T)** (ADR 0012): click to type on the stage, click or double-click text to edit it; font (installed families, plus the embedded Inter), size, colour, under or over the artwork, snap accuracy up to Always); Snap to Pixels; one snap engine for every drawing tool, the Selection tool's moves and Free Transform's handles (point › guide › line › grid › pixel, guides and grid per axis); stage only, never exported, on the CPU and GPU stage. Headless: `view.rulers`, `view.grid_show/grid_snap/grid_set`, `view.guides_show/guides_lock/guides_snap/guides_set`, `view.guide_add/guide_move/guide_delete/guides_clear`, `view.snap_pixels`, `view.aids`, `moving` on `draw.snap`. *Not yet:* units other than px, a movable ruler origin, a symbol's own guides, guides and grid from Animate files, Snap Align and Edit Snapping, snapping while rotating. bold, italic, alignment, letter/line spacing, wrap width; the last style is kept for new text; laid out by `anicraft-text` (`fontdb` discovery, `harfrust` shaping, `skrifa` outlines) and stored as outlines with the source, so rendering never needs the font; Break Apart makes a shape. **Variable-width strokes** (ADR 0018): a stroke's width varies along it through width points (position by arc length, a width on each side as a factor of the stroke width, smooth monotone interpolation); built-in profiles (Uniform, Taper both ends, Taper start, Taper end, Bulge, Pinch) in Properties › Width with a picture of each, and in the drawing tools' options for new strokes; the **Width tool (U)**: hover a stroke to see its width points, drag a handle to widen or narrow (Alt: one side), drag the dot to slide a point, drag anywhere on the stroke to add one, Delete removes; one undo step per drag; rendered by the oracle as trapezoids with outer round joins and cross-section caps, written by SVG and Lottie export as the same filled outlines; shape tweens blend profiles; `.anicraft` keeps them (older files open as uniform). Headless: `stroke.set_profile`, `stroke.set_width_point`, `stroke.delete_width_point`, `stroke.width_at`, `profile` on `element.set_stroke`, the drawing commands and `paint.ink`. Not yet: width data from or to Animate files, saved custom profiles, pressure. **Stroke styles** (ADR 0046): Properties › Style (Solid, Dashed with dash and gap, Dotted with spacing), Cap (None, Round, Square), Join (Miter, Round, Bevel) and Miter limit, also in the drawing tools' options for new strokes; drawn exactly by the oracle (butt and square caps, miters bevelled past the limit, dashes by arc length restarting per subpath, round dots, dashes keeping a width profile's widths; sub-pixel patterns solid and faded) and so by the GPU stage; the Ink Bottle carries the style and the Eyedropper picks it; merge drawing keeps it on cut pieces; shape tweens blend miter limits and dash lengths; SVG, HTML canvas and Lottie export write it. Headless: `stroke.set_style`, and `cap`, `join`, `miter`, `style`, `dash`, `dash_offset`, `dot_spacing` on `element.set_stroke`, the drawing commands and `paint.ink`. Not yet: Animate's ragged, stipple and hatched styles, multi-dash patterns in Properties (kept and editable by command), stroke scale modes and hairlines. **Merge drawing** (ADR 0031, first slice): with Object Drawing (J) off, the editor's default, the shape tools, Pen and Pencil draw raw strokes and fills that cut and join the raw shapes they touch (a fill on top replaces what it covers or joins the same colour, lines cut each other and the fills they cross, a fill hides the lines under it, points within 0.2 px are one; exact Bézier pieces, `anicraft_geom::planar`); one element per fill region and stroke run, selected with Animate's dot pattern; double-click selects connected strokes, or a fill with its outline; a dragged or nudged raw shape merges where it lands; arrows move 1 screen pixel (Shift 10). Headless: `object` on the drawing commands (scripts draw objects by default), `draw.object_drawing`, `draw.drop`, `element.select_connected`. **Selection tool on raw shapes** (ADR 0041): drag an unselected segment or fill edge to bend it through the pointer (every shape sharing the edge bends with it), an unselected corner or end to move it with every raw shape that shares it, a fill to move only that fill; points and selections snap to points, strokes and fill edges within 10 screen px (snap ring), and arrow nudges join ends within 0.2 px at their midpoint or onto a line that stays put; a corner or curve mark by the pointer shows what a drag does; the marquee selects the parts of raw fills and strokes under it, cut at its edges (taken back if nothing uses them, no undo step), and objects wholly inside; each drag one undo step, merged on release. Headless: `path.bend_segment`, `raw.move_point`, `raw.select_rect`, `raw.merge`, `raw.hit`, `snap` on `element.translate`. **Subselection on raw shapes and merging everywhere** (ADR 0042): with Subselection (A), a click on a raw line or fill shows the anchors of everything connected to it (once per shared point; hollow, selected ones solid) and the handles of the selected anchors' curves and of the clicked edge; drag an anchor to move it with the other selected ones and every shape on them, a handle to change the curve in every shape that has the edge, a line or fill to move everything connected; Shift adds or removes anchors; the marquee takes whole shapes on the active layer and selects the anchors inside; arrows nudge 1 screen px (Shift 10); one undo step per gesture, merged on release. **Snap to Objects** (View › Snapping, a magnet under the tools, the selection tools' options) switches the Selection and Subselection tools' snapping. The Paint Bucket's raw fills are exact (up to the lines and fill edges, from the planar merge) and join fills of their colour; pasted raw shapes cut what they land on; raw shapes from Animate files open raw and merged; gradient and bitmap fills take part in the merge. Headless: `raw.subselect`, `raw.subselect_rect`, `raw.points`, `raw.move_handle`, `with` on `raw.move_point`, `draw.snap_to_objects`, `object` on `paint.bucket`. Width profiles take part in the merge since ADR 0093. **Raw points and symbols in place** (ADR 0045): add a point on a raw edge (Pen Alt-click, Subselection double-click; every shape on the edge gets it, the curve unchanged), delete raw points (Delete; the shapes on each are joined across it so the drawing stays connected; points where three edges meet are kept), Make Smooth / Corner on raw points, smooth handle pairs (Alt breaks them; drawing objects too); the Line, Rectangle, Oval, PolyStar and Pen tools snap their points within 10 screen px (Snap to Objects, Ctrl+Shift+/); a double-click on an instance (or Ctrl+E, or a double-click in the Library) opens the symbol in place, the stage dimmed around it, the edit bar showing the path back, and every tool and command works on its timeline. Headless: `raw.add_point`, `raw.delete_points`, `raw.convert_points`, `draw.snap`, `smooth` on `raw.move_handle` and `path.move_points`, `symbol.edit`, `symbol.exit`. Not yet: rotated instances in place (they open upright). Onion skin and Edit Multiple Frames work inside a symbol since ADR 0051. **Brush (B)** (ADR 0033, first slice): paints filled shapes in the fill colour, the exact area the tip sweeps (a distance field traced and fitted with cubics); round or square tip with size, angle and roundness (oval and flat calligraphic tips), pen pressure from a tablet (10–100 % of the size; a mouse paints full size), smoothing relative to the screen, Paint Normal, Paint Fills and Paint Behind; a raw shape that merges with the drawing, or a drawing object with Object Drawing on; a click paints a dab; live preview while dragging; `[` and `]` change the size; one undo step per stroke. Headless: `draw.brush`. **Paint Selection and Paint Inside** (ADR 0080): the paint is clipped to the selected fills' union, or to the fill (or closed-off empty area) the stroke starts in, exactly on the planar map, then merged under the lines; Paint Selection keeps the selection for the next stroke. Not yet: the live preview clipped as it paints. **Brush library, pressure curves and the Paint Brush (Y)** (ADR 0068, first slice): Window › Brush Library with seven built-in brushes made only of parameters (Pencil, Ink Pen, Flat Calligraphy, Round Taper, Marker, Chalk with a deterministic worn edge, Dotted stamps), each with a stroke preview drawn by the renderer; brushes of your own saved by name in your preferences, renamed, deleted (asks first) and moved between computers as a JSON brush set; a pressure curve editor (Linear, Soft, Firm, or your own points) driving size and, optionally, the stroke's opacity; tapers, dab spacing and a rough edge in Properties; Velocity (mouse speed as pressure, off by default); the Paint Brush draws a width-profiled stroke in the stroke colour that the Width tool edits. Headless: `brush.preset_list/save/rename/delete/export/import`, `preset`, `curve`, `as_stroke` and the shaping params on `draw.brush`. **Tilt, texture and scatter** (ADR 0093): per-sample pen tilt (Pointer Events altitude and azimuth in `draw.brush` points) turns the tip by the azimuth and widens it up to 2× as the pen leans (Brush › Tilt: Angle, Width, shown once a pen reports tilt; the Paint Brush's width too), a fixed paper grain of holes (Texture) and dabs thrown either side (Scatter), all vector fills; built-in Charcoal and Spatter (nine brushes). Not yet: reading tilt from the pen on desktop systems (winit 0.30 reports it only on iOS), opacity varying along a stroke, bitmap or art brushes, Eraser presets and pressure. **Eraser (E)** (ADR 0034, first slice): sweeps the Brush's tip and takes what it covers out of the raw shapes on the active layer (the sweep is a fill in the planar merge that comes out empty); Erase Normal, Erase Fills, Erase Lines, Erase Selected Fills and Erase Inside; the Faucet deletes a whole fill or line with a click; drawing objects, symbols and text are kept; a stroke that erases nothing leaves no undo step; live preview, `[` and `]` sizes, one undo step per stroke; double-clicking the tool (or Erase Everything… in its options) clears the visible, unlocked layers at this frame after asking. **Eyedropper (I)** (first slice): a click on a fill picks its paint (solid, or a gradient or bitmap kept where it is on the stage, as Lock Fill) into the fill swatch, a click on a line picks its colour, width and profile; with shapes selected it applies the style to them; then it becomes the Paint Bucket or the Ink Bottle; Shift-click picks into the other swatch only. Headless: `draw.erase`, `draw.faucet`, `draw.erase_all`, `paint.pick`, `paint.apply_style`. Since ADR 0093: width-profiled lines erase and merge, each piece keeping its widths; gradient pieces keep their gradient where it was; inside an open symbol or drawing object the Eraser erases its shapes, and on the stage the status bar says how. Not yet: eraser pressure, picking a bitmap's pixel colour or text styles, an eyedropper cursor. **Colour picker and drawing-tool fixes from user reports** (M1): every colour swatch (the Tools panel's fill and stroke squares too) opens one picker with Animate's web-safe swatches (216 colours, greys and primaries), the colours mixed or typed this session, a hex field and the full mixer; the Tools squares offer No Colour and recolour selected shapes; drawing tools leave nothing selected after a stroke and the Selection tool's box has no handles (they are Free Transform's); Break Apart turns drawing objects into merged drawing; Ctrl-drag on a raw edge adds a corner point; the Pencil and Line show no fill. **Swatches panel** (ADR 0078): Window › Swatches, the document's saved colours and gradients (in the `.anicraft` file; a default set for new documents and older files); click applies to the selection and the tool colour, to the stroke or fill that is active (Tools squares, Colour panel or the panel's toggle; gradients always to the fill); + adds the active colour or gradient, Duplicate, Delete, drag (or Alt+arrows) to reorder, names on hover, Clear (keeps black and white) and Load Default Swatches (ask first, undoable); import and export as a JSON swatch file, GIMP `.gpl` import; the colour picker shows the document's swatches and the fill picker its gradient swatches. Headless: `swatch.list/add/remove/move/duplicate/rename/apply/clear/reset/import/export`. Not yet: swatch folders, sorting by colour, bitmap swatches, Save as Default, XFL swatch import (no public description), Adobe `.aco`/`.ase`/`.clr` (proprietary). The layer footer is icon buttons (New Layer, Delete, New Folder, Up, Down) that fit at any size and language. **Groups and Combine Objects** (ADR 0073): Modify › Group (Ctrl+G) / Ungroup (Ctrl+Shift+G), a group element whose members are a one-layer, one-frame timeline, opened in place by a double-click like a symbol (every tool works inside; raw shapes merge only with each other); nested groups; exports, player, sounds and the Library see inside; XFL `DOMGroup` imports as groups. Combine Objects › Union, Intersect, Punch, Crop on the planar map (analytic area tests). Selection tool: Alt-drag copies, Shift constrains to 45° (Ctrl/⌘-drag corners: #257). **Drawing parity leftovers** (ADR 0080): PolyStar's Shape (Polygon or Star), sides 3–32 and star point size 0–1 in its options (`shape`, `sides`, `point_size` on `draw.star`); a double-click (or Ctrl+E) opens a drawing object in place, its raw shapes merging only with each other, and it closes as one object again (a group of the pieces when an edit leaves several); **Modify › Transform › Envelope**: a Bézier mesh of corner and tangent handles bends the selected shapes and drawing objects, one undo step per drag, baked like Animate's (no Delete Envelope needed), undo restoring the mesh; headless `modify.envelope`, `modify.envelope_move`, `modify.envelope_close`. *Not yet:* gradient and bitmap strokes (proposed in ADR 0080), envelope handles from the keyboard, an envelope on symbols, text or groups, drawing objects edited without opening them (#181), Combine across layers. **Rulers, grid and guides** (ADR 0076): View › Rulers (stage pixels, following zoom and pan, pointer marked); guides per scene saved in the document, dragged from a ruler, moved with the Selection tool, dragged back to delete, Show/Lock/Snap/Edit/Add/Clear (Edit Guides lists and adds them from the keyboard); a grid (spacing, | +| Drawing | Selection (click, shift-add, marquee, drag-move, arrow nudge; **across layers**, ADR 0044: a selection holds artwork on several visible, unlocked layers, the timeline marks them, Properties shows mixed values), **Free Transform (Q)**: scale from corner/edge handles (Shift proportional, Alt from centre), rotate outside corners (Shift snaps 15°), move; one undo step per drag. Rectangle (corner radius), oval, PolyStar, line (45° snap), hand, zoom. **Pen (P)**: click for corners, drag for smooth handles (Alt breaks them), close on the first point, Enter/Esc/last-point click finishes, Backspace takes back a point; one undo step per path. **Pencil (Shift+Y)**: Smooth (Schneider curve fitting with corner detection), Straighten and Ink modes, smoothing relative to the screen. **Subselection (A)**: anchors and handles on selected shapes, drag points/handles (anchors bring their handles; one undo step per drag), point marquee, double-click an edge to add a point, Delete removes points, right-click Make Smooth/Corner; closed shapes' start point and closing edge edit like any other. **Cut/Copy/Paste in Center/Paste in Place** (the clipboard carries symbols across documents). **Rotate 90° CW/CCW, Flip H/V. Align** left/centre/right/top/middle/bottom (Ctrl+Alt+1…6; a single element aligns to the stage) and distribute. Convert to symbol / break apart; arrange; duplicate. Headless: `draw.path`, `draw.freehand`, `path.move_points/add_point/delete_points/convert_point`. **Paint Bucket (K)**: click a fill to recolour it, or inside an outline (one shape or several) to fill the enclosed area; gap size None/Small/Medium/Large closes openings; the fill is traced and curve-fitted into a new shape placed under its lines; open areas say so instead of flooding. **Ink Bottle (S)**: gives the clicked shape the current stroke. Headless: `paint.bucket`, `paint.ink`. Tool options show first in Properties while a drawing tool is active. **Text (T)** (ADR 0012): click to type on the stage, click or double-click text to edit it; font (installed families, plus the embedded Inter), size, colour, under or over the artwork, snap accuracy up to Always); Snap to Pixels; one snap engine for every drawing tool, the Selection tool's moves and Free Transform's handles (point › guide › line › grid › pixel, guides and grid per axis); stage only, never exported, on the CPU and GPU stage. Headless: `view.rulers`, `view.grid_show/grid_snap/grid_set`, `view.guides_show/guides_lock/guides_snap/guides_set`, `view.guide_add/guide_move/guide_delete/guides_clear`, `view.snap_pixels`, `view.aids`, `moving` on `draw.snap`. *Not yet:* units other than px, a movable ruler origin, a symbol's own guides, guides and grid from Animate files, Snap Align and Edit Snapping, snapping while rotating. bold, italic, alignment, letter/line spacing, wrap width; the last style is kept for new text; laid out by `anicraft-text` (`fontdb` discovery, `harfrust` shaping, `skrifa` outlines) and stored as outlines with the source, so rendering never needs the font; Break Apart makes a shape. **Variable-width strokes** (ADR 0018): a stroke's width varies along it through width points (position by arc length, a width on each side as a factor of the stroke width, smooth monotone interpolation); built-in profiles (Uniform, Taper both ends, Taper start, Taper end, Bulge, Pinch) in Properties › Width with a picture of each, and in the drawing tools' options for new strokes; the **Width tool (U)**: hover a stroke to see its width points, drag a handle to widen or narrow (Alt: one side), drag the dot to slide a point, drag anywhere on the stroke to add one, Delete removes; one undo step per drag; rendered by the oracle as trapezoids with outer round joins and cross-section caps, written by SVG and Lottie export as the same filled outlines; shape tweens blend profiles; `.anicraft` keeps them (older files open as uniform). Headless: `stroke.set_profile`, `stroke.set_width_point`, `stroke.delete_width_point`, `stroke.width_at`, `profile` on `element.set_stroke`, the drawing commands and `paint.ink`. Not yet: width data from or to Animate files, saved custom profiles, pressure. **Stroke styles** (ADR 0046): Properties › Style (Solid, Dashed with dash and gap, Dotted with spacing), Cap (None, Round, Square), Join (Miter, Round, Bevel) and Miter limit, also in the drawing tools' options for new strokes; drawn exactly by the oracle (butt and square caps, miters bevelled past the limit, dashes by arc length restarting per subpath, round dots, dashes keeping a width profile's widths; sub-pixel patterns solid and faded) and so by the GPU stage; the Ink Bottle carries the style and the Eyedropper picks it; merge drawing keeps it on cut pieces; shape tweens blend miter limits and dash lengths; SVG, HTML canvas and Lottie export write it. Headless: `stroke.set_style`, and `cap`, `join`, `miter`, `style`, `dash`, `dash_offset`, `dot_spacing` on `element.set_stroke`, the drawing commands and `paint.ink`. Not yet: Animate's ragged, stipple and hatched styles, multi-dash patterns in Properties (kept and editable by command), stroke scale modes and hairlines. **Merge drawing** (ADR 0031, first slice): with Object Drawing (J) off, the editor's default, the shape tools, Pen and Pencil draw raw strokes and fills that cut and join the raw shapes they touch (a fill on top replaces what it covers or joins the same colour, lines cut each other and the fills they cross, a fill hides the lines under it, points within 0.2 px are one; exact Bézier pieces, `anicraft_geom::planar`); one element per fill region and stroke run, selected with Animate's dot pattern; double-click selects connected strokes, or a fill with its outline; a dragged or nudged raw shape merges where it lands; arrows move 1 screen pixel (Shift 10). Headless: `object` on the drawing commands (scripts draw objects by default), `draw.object_drawing`, `draw.drop`, `element.select_connected`. **Selection tool on raw shapes** (ADR 0041): drag an unselected segment or fill edge to bend it through the pointer (every shape sharing the edge bends with it), an unselected corner or end to move it with every raw shape that shares it, a fill to move only that fill; points and selections snap to points, strokes and fill edges within 10 screen px (snap ring), and arrow nudges join ends within 0.2 px at their midpoint or onto a line that stays put; a corner or curve mark by the pointer shows what a drag does; the marquee selects the parts of raw fills and strokes under it, cut at its edges (taken back if nothing uses them, no undo step), and objects wholly inside; each drag one undo step, merged on release. Headless: `path.bend_segment`, `raw.move_point`, `raw.select_rect`, `raw.merge`, `raw.hit`, `snap` on `element.translate`. **Subselection on raw shapes and merging everywhere** (ADR 0042): with Subselection (A), a click on a raw line or fill shows the anchors of everything connected to it (once per shared point; hollow, selected ones solid) and the handles of the selected anchors' curves and of the clicked edge; drag an anchor to move it with the other selected ones and every shape on them, a handle to change the curve in every shape that has the edge, a line or fill to move everything connected; Shift adds or removes anchors; the marquee takes whole shapes on the active layer and selects the anchors inside; arrows nudge 1 screen px (Shift 10); one undo step per gesture, merged on release. **Snap to Objects** (View › Snapping, a magnet under the tools, the selection tools' options) switches the Selection and Subselection tools' snapping. The Paint Bucket's raw fills are exact (up to the lines and fill edges, from the planar merge) and join fills of their colour; pasted raw shapes cut what they land on; raw shapes from Animate files open raw and merged; gradient and bitmap fills take part in the merge. Headless: `raw.subselect`, `raw.subselect_rect`, `raw.points`, `raw.move_handle`, `with` on `raw.move_point`, `draw.snap_to_objects`, `object` on `paint.bucket`. Width profiles take part in the merge since ADR 0093. **Raw points and symbols in place** (ADR 0045): add a point on a raw edge (Pen Alt-click, Subselection double-click; every shape on the edge gets it, the curve unchanged), delete raw points (Delete; the shapes on each are joined across it so the drawing stays connected; points where three edges meet are kept), Make Smooth / Corner on raw points, smooth handle pairs (Alt breaks them; drawing objects too); the Line, Rectangle, Oval, PolyStar and Pen tools snap their points within 10 screen px (Snap to Objects, Ctrl+Shift+/); a double-click on an instance (or Ctrl+E, or a double-click in the Library) opens the symbol in place, the stage dimmed around it, the edit bar showing the path back, and every tool and command works on its timeline. Headless: `raw.add_point`, `raw.delete_points`, `raw.convert_points`, `draw.snap`, `smooth` on `raw.move_handle` and `path.move_points`, `symbol.edit`, `symbol.exit`. Not yet: rotated instances in place (they open upright). Onion skin and Edit Multiple Frames work inside a symbol since ADR 0051. **Brush (B)** (ADR 0033, first slice): paints filled shapes in the fill colour, the exact area the tip sweeps (a distance field traced and fitted with cubics); round or square tip with size, angle and roundness (oval and flat calligraphic tips), pen pressure from Thinnest (0.01 stage px by default) to the size you set, and on macOS the stylus itself (ADR 0096); a mouse uses speed as pressure, which is on for the plain Brush; smoothing 0 keeps the points you drew, Paint Normal, Paint Fills and Paint Behind; a raw shape that merges with the drawing, or a drawing object with Object Drawing on; a click paints a dab; live preview while dragging; `[` and `]` change the size; one undo step per stroke. Headless: `draw.brush`. **Paint Selection and Paint Inside** (ADR 0080): the paint is clipped to the selected fills' union, or to the fill (or closed-off empty area) the stroke starts in, exactly on the planar map, then merged under the lines; Paint Selection keeps the selection for the next stroke. Not yet: the live preview clipped as it paints. **Brush library, pressure curves and the Paint Brush (Y)** (ADR 0068, first slice): Window › Brush Library with seven built-in brushes made only of parameters (Pencil, Ink Pen, Flat Calligraphy, Round Taper, Marker, Chalk with a deterministic worn edge, Dotted stamps), each with a stroke preview drawn by the renderer; brushes of your own saved by name in your preferences, renamed, deleted (asks first) and moved between computers as a JSON brush set; a pressure curve editor (Linear, Soft, Firm, or your own points) driving size and, optionally, the stroke's opacity; tapers, dab spacing and a rough edge in Properties; Speed as pressure (on for the plain Brush, a stand-in for a mouse; a pen still uses its own pressure when it is off); the Paint Brush draws a width-profiled stroke in the stroke colour that the Width tool edits. Headless: `brush.preset_list/save/rename/delete/export/import`, `preset`, `curve`, `as_stroke` and the shaping params on `draw.brush`. **Tilt, texture and scatter** (ADR 0093): per-sample pen tilt (Pointer Events altitude and azimuth in `draw.brush` points) turns the tip by the azimuth and widens it up to 2× as the pen leans (Brush › Tilt: Angle, Width, shown once a pen reports tilt; the Paint Brush's width too), a fixed paper grain of holes (Texture) and dabs thrown either side (Scatter), all vector fills; built-in Charcoal and Spatter (nine brushes). Not yet: reading tilt from the pen (macOS reads pressure only, ADR 0096; winit 0.30 reports tilt only on iOS), opacity varying along a stroke, bitmap or art brushes, Eraser presets and pressure. **Eraser (E)** (ADR 0034, first slice): sweeps the Brush's tip and takes what it covers out of the raw shapes on the active layer (the sweep is a fill in the planar merge that comes out empty); Erase Normal, Erase Fills, Erase Lines, Erase Selected Fills and Erase Inside; the Faucet deletes a whole fill or line with a click; drawing objects, symbols and text are kept; a stroke that erases nothing leaves no undo step; live preview, `[` and `]` sizes, one undo step per stroke; double-clicking the tool (or Erase Everything… in its options) clears the visible, unlocked layers at this frame after asking. **Eyedropper (I)** (first slice): a click on a fill picks its paint (solid, or a gradient or bitmap kept where it is on the stage, as Lock Fill) into the fill swatch, a click on a line picks its colour, width and profile; with shapes selected it applies the style to them; then it becomes the Paint Bucket or the Ink Bottle; Shift-click picks into the other swatch only. Headless: `draw.erase`, `draw.faucet`, `draw.erase_all`, `paint.pick`, `paint.apply_style`. Since ADR 0093: width-profiled lines erase and merge, each piece keeping its widths; gradient pieces keep their gradient where it was; inside an open symbol or drawing object the Eraser erases its shapes, and on the stage the status bar says how. Not yet: eraser pressure, picking a bitmap's pixel colour or text styles, an eyedropper cursor. **Colour picker and drawing-tool fixes from user reports** (M1): every colour swatch (the Tools panel's fill and stroke squares too) opens one picker with Animate's web-safe swatches (216 colours, greys and primaries), the colours mixed or typed this session, a hex field and the full mixer; the Tools squares offer No Colour and recolour selected shapes; drawing tools leave nothing selected after a stroke and the Selection tool's box has no handles (they are Free Transform's); Break Apart turns drawing objects into merged drawing; Ctrl-drag on a raw edge adds a corner point; the Pencil and Line show no fill. **Swatches panel** (ADR 0078): Window › Swatches, the document's saved colours and gradients (in the `.anicraft` file; a default set for new documents and older files); click applies to the selection and the tool colour, to the stroke or fill that is active (Tools squares, Colour panel or the panel's toggle; gradients always to the fill); + adds the active colour or gradient, Duplicate, Delete, drag (or Alt+arrows) to reorder, names on hover, Clear (keeps black and white) and Load Default Swatches (ask first, undoable); import and export as a JSON swatch file, GIMP `.gpl` import; the colour picker shows the document's swatches and the fill picker its gradient swatches. Headless: `swatch.list/add/remove/move/duplicate/rename/apply/clear/reset/import/export`. Not yet: swatch folders, sorting by colour, bitmap swatches, Save as Default, XFL swatch import (no public description), Adobe `.aco`/`.ase`/`.clr` (proprietary). The layer footer is icon buttons (New Layer, Delete, New Folder, Up, Down) that fit at any size and language. **Groups and Combine Objects** (ADR 0073): Modify › Group (Ctrl+G) / Ungroup (Ctrl+Shift+G), a group element whose members are a one-layer, one-frame timeline, opened in place by a double-click like a symbol (every tool works inside; raw shapes merge only with each other); nested groups; exports, player, sounds and the Library see inside; XFL `DOMGroup` imports as groups. Combine Objects › Union, Intersect, Punch, Crop on the planar map (analytic area tests). Selection tool: Alt-drag copies, Shift constrains to 45° (Ctrl/⌘-drag corners: #257). **Drawing parity leftovers** (ADR 0080): PolyStar's Shape (Polygon or Star), sides 3–32 and star point size 0–1 in its options (`shape`, `sides`, `point_size` on `draw.star`); a double-click (or Ctrl+E) opens a drawing object in place, its raw shapes merging only with each other, and it closes as one object again (a group of the pieces when an edit leaves several); **Modify › Transform › Envelope**: a Bézier mesh of corner and tangent handles bends the selected shapes and drawing objects, one undo step per drag, baked like Animate's (no Delete Envelope needed), undo restoring the mesh; headless `modify.envelope`, `modify.envelope_move`, `modify.envelope_close`. *Not yet:* gradient and bitmap strokes (proposed in ADR 0080), envelope handles from the keyboard, an envelope on symbols, text or groups, drawing objects edited without opening them (#181), Combine across layers. **Rulers, grid and guides** (ADR 0076): View › Rulers (stage pixels, following zoom and pan, pointer marked); guides per scene saved in the document, dragged from a ruler, moved with the Selection tool, dragged back to delete, Show/Lock/Snap/Edit/Add/Clear (Edit Guides lists and adds them from the keyboard); a grid (spacing, | | Plug-ins | **WebAssembly plug-ins** (ADR 0017, `docs/plugins.md`): sandboxed modules run by `wasmi` with no imports but the AniCraft ABI (`anicraft.call`/`anicraft.response`: JSON requests that run registry commands, read a compact document view or the active layer's elements, and log); fuel (10⁹ instructions), memory (256 MiB), wall-clock (5 s), request, size and log limits, a fresh instance per run; file, plug-in, undo and clipboard commands refused. `plugin.install/list/run/remove/reload`; a run is atomic and one undo step named after the plug-in. App: **Commands** menu (Plug-ins, Install, Manage, Reload), palette entries, a dialog generated from the manifest's parameters with a live preview; plug-ins load from `/plugins` or `ANICRAFT_PLUGINS`; CLI `--plugins`. Samples in Rust (Star Field, Jitter Selection, guest SDK) in `examples/plugins`. Frame scripts: see Interactivity. **Exporter and ease plug-ins** (ADR 0088): `kind: "exporter"` declares a format (name, extension, options) and gets evaluated frames (`movie`, `frame`: the flattened display list, never the document), returning the file's bytes from `ac_export` (64 MiB cap, 5·10¹⁰ instructions, 10 min); File › Export lists it with an Export window (options, frames, file, progress bar and Cancel on a worker thread); `file.export_plugin`, `file.export_formats`, `anicraft-cli export --format ` and `formats`. `kind: "ease"` declares named eases, `ac_ease(i, t)` sampled into 257 values at load (NaN, unbounded or end-missing eases refused); `Easing::Sampled` keeps the table in the document, so it plays the same without the plug-in; the ease menus list them under Plug-in eases; `{plugin, ease}` easing params. Samples: Plain Text Frames and Stepped Eases, built and run in CI. Not yet: plug-in formats in Publish, bitmaps' pixels and sounds for exporters, ease plug-ins with parameters, background runs of command plug-ins, a binary document view. | | Interactivity | **Frame actions, frame scripts and Test Movie, first slice** (ADR 0024): a typed timeline action IR (`anicraft-actions`: stop, play, go to a frame or label in a scene, next/previous frame and scene, trace, set x/y/alpha/visible/rotation/scale, instance-name target paths) on keyframes and on button and movie clip instances (click, press, release, over, out); a pure, deterministic, budgeted player in `anicraft-doc` (`step(doc, &state, &events)`; movie clip playheads, button Up/Over/Down states with Hit-frame hit tests, script-set properties) drawn by `evaluate_player`, while `evaluate` and exports stay script-free. **Actions panel** (F9: structured lists, targets/labels/scenes picked from the document; movie clip timelines too), instance name and events in Properties, an "a" on keyframes with actions. **Frame scripts** on the plug-in host (`kind: "frame-script"`, entry points, `player`/`action` ops, 20 M instructions per frame shared, no document access). **Test Movie** (Control › Test Movie, Ctrl+Enter): the player at the document frame rate in its own window with the pointer, restart/pause/step and an Output panel. **A window of its own on the desktop** (ADR 0062): sized to the stage at 100 % (smaller to fit the screen), titled with the document, back where it was last (not on Wayland, where the desktop places windows), with the keyboard when it opens; Esc or Ctrl+W (⌘W) closes it, Ctrl+Enter restarts it in place, and the editor's Ctrl/Alt shortcuts work from it; floating inside the editor in the web build, where the system can't open windows (it says so), or by choice in Preferences › Test Movie. Not yet: full screen, zoom in the movie window, remembering its size. Headless: `actions.set/clear/list`, `instance.set_name`, `control.test_movie` (timed pointer events, returns output and state). **HTML canvas export** (ADR 0028, `file.export_html`, File › Export › Export HTML Canvas…): one self-contained page (data, player and drawing code inline; images and sounds embedded) that plays in any browser with canvas 2D; every used timeline baked frame by frame (tweens of every kind exact, identical paths, paints and frames stored once), movie clips, buttons with Hit areas and every action run by `player.js`, a port of the Rust player checked against it step by step in Boa (state, output and display list); masks and fading/blending groups on offscreen layers; **filters and Subtract/Invert in the page** (ADR 0072: `filters.js`, a port of the renderer's filters run over the group's pixels on a layer that reaches past the canvas by the filters' reach, Subtract and Invert composited per pixel, results kept while the group is unchanged; equal to the oracle's pixels within 9.3e-4 in Boa; 3.4 ms a frame for the demo's filters at 1080p while they change); Web Audio for Event/Start/Stop/Stream sounds; bitmap tints, 9-slice scaling and plug-in frame scripts reported. Not yet: 9-slice in the page, keeping a moving filtered group's pixels, filters off the main thread. **Kept frame scripts** (ADR 0013 tier 1): scripts written in another language (an Animate document's ActionScript) kept verbatim on keyframes with their language, symbol linkage classes and the document class; the Actions panel's **Script** view (monospace editor with line numbers, language, live and undoable), the same "a" marker, Class rows in Properties; `script.set/clear/list/set_class`; **ActionScript timeline subset runs** (ADR 0013 tier 2): a hand-written, bounded lexer and parser (`anicraft_actions::as3`) lower stop/play/go to/next and previous frame and scene on instance-name paths, mouse listeners (click, down, up, roll over and out; named or inline functions, `currentTarget`), `trace` and property settings to the action IR, compiled once per script; anything else is reported with line and column and the script doesn't run (never half-run), `navigateToURL` and the like are ignored and noted; the player keeps listeners per instance, Test Movie's Output says which scripts run and which don't and why, the Script view lists problems in place, the XFL report and `script.list/check` say what runs, and the HTML page runs the same steps (`player.js` checked against the Rust player). **ActionScript small logic** (ADR 0052): scripts compile to a small typed script IR evaluated by the player with fuel (100,000 per script or listener call, 1,000,000 per step; an endless loop or a run-time error stops its script with an Output line and the movie plays on): typed variables (Number, int, uint, Boolean, String, untyped) kept per timeline instance across its frame scripts, expressions as ECMA-262 has them, `if`/`else`, `for`/`while`/`do`, functions with parameters, results and recursion, called from any frame of the same timeline (a timeline-level link step; names that aren't there keep the script from running, said in place), property reads of instances and timelines (`x`, `y`, `alpha`, `visible`, `rotation`, `scaleX/Y`, `currentFrame`, `totalFrames`, `currentLabel`, `mouseX/Y`, `name`), `removeEventListener`, `Event.ENTER_FRAME`, `KeyboardEvent.KEY_DOWN/KEY_UP` on the stage with `e.keyCode`, `e.target`/`e.currentTarget`, seeded `Math.random()`; keyboard in Test Movie and the HTML page, `control.test_movie` key events and `seed`; `player.js` mirrors the evaluator, checked step by step in Boa. Not yet: ActionScript `switch`, `for in`, arrays and objects, string methods, closures, event bubbling, mouse listeners on the stage, ActionScript 1.0/2.0, scripts on instances, filters and keyboard focus in the HTML page, sound actions, keyboard events, variables, persistent script state, masks in hit tests, a separate OS window. **Buttons, complete** (ADR 0057): a button open in place names its frames Up, Over, Down and Hit on the timeline and shows its Hit area translucent over the other states; Test Movie has **Show hit areas**; sounds on its frames play as its state changes. | | Headless | CLI: info, render, render-seq, **gif**, **apng**, **webp**, commands, exec, run, serve (JSON-lines). **MCP server** (`anicraft-cli mcp`, `docs/mcp.md`): stdio JSON-RPC, protocol 2025-06-18 (and 2025-03-26, 2024-11-05); five tools over the command registry (list, run, run a script, describe the document, render a frame as a PNG picture); confirmation tiers and undo apply as in the app. | @@ -61,7 +61,7 @@ titled with its milestone. | M4 | Text from Animate files | XFL import, text engine (M5 first slice) | | M5 | Audio layers: import, waveform, playback sync | timeline, layers | | M6 | ~~WebAssembly plug-ins (U2)~~ ✅ first slice (ADR 0017); ~~frame scripts and Test Movie on the plug-in host~~ ✅ first slice (ADR 0024); ~~HTML canvas runtime export~~ ✅ first slice (ADR 0028); ~~ActionScript tier 1: keep scripts~~ ✅ (ADR 0013); ~~ActionScript tier 2: run the timeline subset~~ ✅ (ADR 0013); ~~ActionScript tier 2.5: small logic~~ ✅ (ADR 0052); ~~exporter and easing plug-ins~~ ✅ (ADR 0088); next: command macros, or growing the ActionScript subset (`switch`, arrays, event bubbling) | command registry, plug-in host, action IR and player | -| M7 | ~~Variable-width strokes~~ ✅ (ADR 0018), ~~camera layer with depth~~ ✅ first slice (ADR 0027), ~~camera depth, attaching and colour~~ ✅ (ADR 0082), ~~Bone tool and IK~~ ✅ first slice (ADR 0043), ~~shape armatures and the Bind tool~~ ✅ first slice (ADR 0071), ~~painted weights and reshaping~~ ✅ (ADR 0090), ~~bone springs and joint translation~~ ✅ (ADR 0084), ~~Asset Warp~~ ✅ first slice (ADR 0079), ~~Brush tool~~ ✅ first slice (ADR 0033), ~~Eraser and Eyedropper~~ ✅ first slice (ADR 0034); ~~brush library and pressure curves~~ ✅ first slice (ADR 0068), ~~tilt, texture, profiles in the merge~~ ✅ (ADR 0093); next: reading pen tilt from the toolkit, art brushes | strokes, Pen and Pencil, width profiles, timeline evaluation | +| M7 | ~~Variable-width strokes~~ ✅ (ADR 0018), ~~camera layer with depth~~ ✅ first slice (ADR 0027), ~~camera depth, attaching and colour~~ ✅ (ADR 0082), ~~Bone tool and IK~~ ✅ first slice (ADR 0043), ~~shape armatures and the Bind tool~~ ✅ first slice (ADR 0071), ~~painted weights and reshaping~~ ✅ (ADR 0090), ~~bone springs and joint translation~~ ✅ (ADR 0084), ~~Asset Warp~~ ✅ first slice (ADR 0079), ~~Brush tool~~ ✅ first slice (ADR 0033), ~~Eraser and Eyedropper~~ ✅ first slice (ADR 0034); ~~brush library and pressure curves~~ ✅ first slice (ADR 0068), ~~tilt, texture, profiles in the merge~~ ✅ (ADR 0093); next: reading pen tilt (macOS reads pressure, ADR 0096), art brushes | strokes, Pen and Pencil, width profiles, timeline evaluation | | M8 | ~~Crash-recovery autosave~~ ✅, ~~accessibility first pass~~ ✅ (ADR 0022), ~~installers and signing~~ ✅ first slice (ADR 0026), ~~portable mode and AppImage desktop integration~~ ✅ (ADR 0094), ~~web build~~ ✅ first slice (ADR 0036), ~~localisation~~ ✅ first slice (ADR 0040), ~~Find and Replace and the Movie Explorer~~ ✅ first slice (ADR 0058), ~~drops, recent files and the start screen~~ ✅ first slice (ADR 0059); ~~templates and drops on the timeline~~ ✅ first slice (ADR 0065); ~~Publish Settings and Publish~~ ✅ first slice (ADR 0060); ~~Test Movie in its own window and macOS document opening~~ ✅ first slice (ADR 0062); ~~accessibility second pass~~ ✅ (ADR 0064); ~~a light theme~~ ✅ (ADR 0077); ~~CI on macOS and Windows~~ ✅ (ADR 0067); ~~dockable panels and workspaces~~ ✅ first and second slices (ADR 0075, ADR 0087); next: live screen-reader passes | `.anicraft` format, session, AccessKit in eframe | Tasks that wait on an open track: XFL motion tweens (on M2's motion tweens); frame scripts and diff --git a/src/app.rs b/src/app.rs index da2d4af3..a92facdb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -433,6 +433,18 @@ pub struct App { pub filter_naming: crate::panels::FilterNaming, pub brush_samples: Vec<(anicraft_engine::geom::Vec2, Option)>, pub pen_pressure: Option, + /// The press came from a pen, so speed must not replace it (ADR 0096). + pub brush_from_pen: bool, + /// When the press happened, and how far and long the opening dash has run. + pub brush_down: f64, + pub brush_open_dist: f32, + pub brush_open_time: f32, + /// Recent raw pointer positions, newest last. Smoothing averages these. + pub brush_hand: Vec, + /// The stroke just committed, kept on screen until the stage picture shows it. + pub brush_latch: Vec<(anicraft_engine::geom::Vec2, Option)>, + pub brush_latch_state: Option, + pub brush_latch_erase: bool, /// Whether a pointer event has carried a pen's tilt this session (ADR 0093). Until one has, /// the brushes' Tilt options stay hidden: they would do nothing. pub pen_tilt_seen: bool, @@ -718,6 +730,14 @@ impl App { filter_naming: Default::default(), brush_samples: Vec::new(), pen_pressure: None, + brush_from_pen: false, + brush_down: 0.0, + brush_open_dist: 0.0, + brush_open_time: 0.0, + brush_hand: Vec::new(), + brush_latch: Vec::new(), + brush_latch_state: None, + brush_latch_erase: false, pen_tilt_seen: false, eraser: crate::erasertool::EraserOptions::default(), bucket_gap: GapSize::default(), @@ -3047,7 +3067,7 @@ impl App { } pub fn zoom_about(&mut self, canvas: egui::Rect, screen: egui::Pos2, new_zoom: f32) { - let new_zoom = new_zoom.clamp(0.05, 64.0); + let new_zoom = new_zoom.clamp(0.05, 256.0); let size = egui::vec2(self.s.doc.stage.width as f32, self.s.doc.stage.height as f32); let origin = canvas.center() + self.view.pan - size * self.view.zoom * 0.5; let stage_pt = (screen - origin) / self.view.zoom; diff --git a/src/architecture_tests.rs b/src/architecture_tests.rs index c1cb4244..60b6133e 100644 --- a/src/architecture_tests.rs +++ b/src/architecture_tests.rs @@ -7,7 +7,7 @@ use std::path::Path; /// Each crate's layer, as AGENTS.md documents it. The shell (`anicraft`, `src/`) is L5. const LAYERS: &[(&str, u8)] = - &[("geom", 0), ("xml", 0), ("plugins", 0), ("audio", 0), ("actions", 0), ("guide", 0), ("doc", 1), ("text", 1), ("render", 2), ("xfl", 2), ("svg", 2), ("engine", 3), ("gpu", 3), ("cli", 4)]; + &[("geom", 0), ("xml", 0), ("plugins", 0), ("audio", 0), ("actions", 0), ("guide", 0), ("pen-macos", 0), ("doc", 1), ("text", 1), ("render", 2), ("xfl", 2), ("svg", 2), ("engine", 3), ("gpu", 3), ("cli", 4)]; const SHELL_LAYER: u8 = 5; /// UI crates only the shell may use. const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui-wgpu", "egui_glow"]; diff --git a/src/brush_panel.rs b/src/brush_panel.rs index d7e9f6ec..21e6839a 100644 --- a/src/brush_panel.rs +++ b/src/brush_panel.rs @@ -339,7 +339,7 @@ pub fn props(app: &mut App, ui: &mut egui::Ui, row: impl Fn(&mut egui::Ui, &str, }); }); row_prose(ui, tr!("Mouse"), &mut |ui| { - crate::theme::checkbox(ui, &mut o.velocity, tr!("Speed as pressure")).on_hover_text(tr!("Without a pen: drawing faster makes the stroke thinner, as a pen pressed lightly would")); + crate::theme::checkbox(ui, &mut o.velocity, tr!("Speed as pressure")).on_hover_text(tr!("A pen still uses its own pressure when this is off, from Thinnest at a light touch, including the start. On, a fast mouse stroke thins the same way. The plain Brush starts with it on.")); }); // Tilt (ADR 0093): shown once a pen has reported its tilt this session. Until then the // options would do nothing, so they stay out of the way (brushes keep their tilt settings). diff --git a/src/brushtool.rs b/src/brushtool.rs index 9a3c85dd..cfd1475f 100644 --- a/src/brushtool.rs +++ b/src/brushtool.rs @@ -19,8 +19,10 @@ use serde_json::json; /// The tool options (view state, as in Animate: they are the tool's, not the document's). #[derive(Clone, Copy, Debug, PartialEq)] pub struct BrushOptions { - /// Diameter (or side), stage units. + /// Diameter (or side) at full pressure, stage units. pub size: f32, + /// Diameter at no pressure, stage units. Never above `size`. + pub min_size: f32, pub shape: TipShape, /// Degrees, clockwise. pub angle: f32, @@ -33,7 +35,8 @@ pub struct BrushOptions { pub curve: PressureCurve, pub pressure_size: bool, pub pressure_opacity: bool, - /// With a mouse, the hand's speed stands in for pressure (faster is thinner). Off by default. + /// With a mouse, the hand's speed stands in for pressure (faster is thinner). On by default. + /// A pen uses its own pressure either way (ADR 0096). pub velocity: bool, /// Percent of the stroke's length. pub taper_start: f32, @@ -52,14 +55,15 @@ pub struct BrushOptions { } impl Default for BrushOptions { - /// The plain brush (ADR 0033). + /// The plain brush opens raw: smoothing off, a hairline at no pressure, and speed standing + /// in for a mouse (ADR 0096). A preset you pick keeps its own smoothing and Thinnest. fn default() -> Self { - BrushOptions::from_preset(&BrushPreset::default()) + BrushOptions { smoothing: 0.0, velocity: true, min_size: 0.01, ..BrushOptions::from_preset(&BrushPreset::default()) } } } -/// Sizes the tool offers (the command takes 0.1..2000). -pub const SIZE_MIN: f32 = 1.0; +/// Sizes the tool offers (the command takes 0.01..2000). +pub const SIZE_MIN: f32 = 0.01; pub const SIZE_MAX: f32 = 400.0; impl BrushOptions { @@ -71,6 +75,7 @@ impl BrushOptions { fn from_preset(p: &BrushPreset) -> BrushOptions { BrushOptions { size: p.size, + min_size: p.min_size, shape: p.shape.tip(), angle: p.angle, roundness: p.roundness, @@ -101,6 +106,7 @@ impl BrushOptions { BrushPreset { name: name.to_string(), size: self.size, + min_size: self.min_size.min(self.size), shape: Shape::from_tip(self.shape), angle: self.angle, roundness: self.roundness, @@ -128,7 +134,7 @@ impl BrushOptions { /// The size factor the tip has at a measured pressure (for previews). pub fn factor(&self, pressure: Option) -> f32 { - size_factor(if self.pressure_size { self.curve.map(pressure.unwrap_or(1.0)) } else { 1.0 }) + size_factor(if self.pressure_size { self.curve.map(pressure.unwrap_or(1.0)) } else { 1.0 }, self.min_size, self.size) } /// The tip mapped onto the stage, for previews: unit circle or square → the tip at `at`. @@ -171,6 +177,9 @@ pub fn resize(app: &mut App, larger: bool) { Tool::Brush | Tool::PaintBrush => { let o = options_mut(app); o.size = step_size(o.size, larger); + if o.min_size > o.size { + o.min_size = o.size; + } let size = o.size; app.status_ok(tr!("Brush size {size} px", size = size)); } @@ -187,8 +196,14 @@ pub fn resize(app: &mut App, larger: bool) { } } -/// The pen's pressure this frame, when a tablet reports it (egui gives it with touch events). +/// The pen's pressure this frame, when a tablet reports it. +/// +/// On macOS that is the stylus (a tip-down tablet point). It is used whether or not Speed as +/// pressure is on. egui's touch force is the other path. A plain mouse has no pressure. pub fn frame_pressure(ctx: &egui::Context) -> Option { + if let Some(p) = crate::platform::pen_pressure() { + return Some(p); + } ctx.input(|i| { i.raw.events.iter().rev().find_map(|e| match e { egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), @@ -214,37 +229,92 @@ pub fn note_tilt(app: &mut App, tilt: Option } } -/// Starts a stroke at stage point `p` at time `now` (seconds). A mouse paints at full -/// pressure, or from half pressure when its speed stands in for it. +/// Starts a stroke at stage point `p` at time `now` (seconds). +/// +/// A pen starts at that sample, which can be the thinnest setting. A mouse does not invent a +/// width yet: with Speed as pressure the first movement sets it, and without it the stroke +/// is full width. pub fn start(app: &mut App, p: Vec2, pressure: Option, now: f64) -> Drag { - let pressure = pressure.or_else(|| options(app).velocity.then_some(VELOCITY_START)); + let pressure = pressure.filter(|p| p.is_finite()).map(|p| p.clamp(0.0, 1.0)); + app.brush_from_pen = pressure.is_some(); app.pen_pressure = pressure; app.brush_clock = now; + app.brush_down = now; + app.brush_open_dist = 0.0; + app.brush_open_time = 0.0; + app.brush_hand.clear(); + app.brush_hand.push(p); app.brush_samples = vec![(p, pressure)]; Drag::Brush } -/// The pressure a mouse stroke starts at when its speed stands in for it. +/// Stand-in when a velocity sample has no previous pressure. Not the stroke's first dab. pub const VELOCITY_START: f32 = 0.5; /// Screen speed (points a second) at which a velocity stroke is thinnest. const VELOCITY_FAST: f32 = 2400.0; +/// Screen points whose speed is the width of the whole opening, including the press. +const OPENING_PTS: f32 = 12.0; +/// A pen sample this soon after the press replaces the mouse event the system sent with it. +const PEN_OPEN_SECS: f64 = 0.03; + +/// The width speed stands in for, before it is eased: full when the hand is still, down to +/// none (the thinnest setting) at [`VELOCITY_FAST`]. `None` when the interval is not a movement. +pub fn velocity_target(distance_pts: f32, dt: f32) -> Option { + if !(distance_pts.is_finite() && dt.is_finite() && dt > 1e-4 && distance_pts >= 0.0) { + return None; + } + Some(1.0 - (speed_of(distance_pts, dt))) +} + +fn speed_of(distance_pts: f32, dt: f32) -> f32 { + (distance_pts / dt / VELOCITY_FAST).clamp(0.0, 1.0) +} -/// The pressure speed stands in for (ADR 0068): full when the hand is still, down to a fifth -/// at `VELOCITY_FAST` and beyond, eased from the last value so one jerky event doesn't show. -pub fn velocity_pressure(prev: f32, distance_pts: f32, dt: f32) -> f32 { +/// The pressure speed stands in for. `follow` is how far this sample moves (`1` is this +/// sample's own speed). Smoothing 0 uses `1`. Higher smoothing uses a fraction. +pub fn velocity_pressure(prev: f32, distance_pts: f32, dt: f32, follow: f32) -> f32 { let prev = if prev.is_finite() { prev.clamp(0.0, 1.0) } else { VELOCITY_START }; - if !(distance_pts.is_finite() && dt.is_finite() && dt > 1e-4) { - return prev; + let Some(target) = velocity_target(distance_pts, dt) else { return prev }; + let follow = if follow.is_finite() { follow.clamp(0.0, 1.0) } else { 0.3 }; + prev + (target - prev) * follow +} + +/// Gives every sample `pressure`, so the opening's first real reading is the start as well. +pub fn stamp_pressure(samples: &mut [(Vec2, Option)], pressure: f32) { + if !pressure.is_finite() { + return; + } + let p = pressure.clamp(0.0, 1.0); + for (_, s) in samples { + *s = Some(p); } - let speed = distance_pts / dt; - let target = 1.0 - 0.8 * (speed / VELOCITY_FAST).clamp(0.0, 1.0); - prev + (target - prev) * 0.3 } -/// The stroke's samples as command points (with pressure when a pen gave any). +/// The stroke's samples as command points. +/// +/// A sample that is still waiting takes the first real reading, not full pressure, so the +/// start is not a fat dot in front of a thin line. pub fn points(samples: &[(Vec2, Option)]) -> Vec { - let pen = samples.iter().any(|(_, p)| p.is_some()); - samples.iter().map(|(v, p)| if pen { json!([v.x, v.y, p.unwrap_or(1.0)]) } else { json!([v.x, v.y]) }).collect() + let Some(first) = samples.iter().find_map(|(_, p)| *p) else { + return samples.iter().map(|(v, _)| json!([v.x, v.y])).collect(); + }; + let mut prev = first; + let mut seen = false; + samples + .iter() + .map(|(v, p)| { + let p = if let Some(p) = *p { + seen = true; + prev = p; + p + } else if !seen { + first + } else { + prev + }; + json!([v.x, v.y, p]) + }) + .collect() } /// A tip shape's name in commands. @@ -255,32 +325,97 @@ pub fn shape_key(s: TipShape) -> &'static str { } } -/// Adds a sample at time `now` (closer than half a screen point adds nothing unless the -/// pressure moved). +/// How many raw pointer samples the stabilizer looks back over. +const HAND: usize = 12; + +/// The point the stroke follows. Smoothing 0 is the latest sample. Higher smoothing is a +/// weighted average of the recent hand, newest heaviest. +pub fn stabilize(raw: &[Vec2], smoothing: f32) -> Vec2 { + let Some(&last) = raw.iter().rev().find(|p| p.is_finite()) else { return Vec2::ZERO }; + let t = if smoothing.is_finite() { (smoothing / 100.0).clamp(0.0, 1.0) } else { 0.0 }; + if t <= 1e-4 { + return last; + } + let n = (1.0 + t * (HAND as f32 - 1.0)).round() as usize; + let mut acc = Vec2::ZERO; + let mut wsum = 0.0; + let mut k = 0.0; + for p in raw.iter().skip(raw.len().saturating_sub(n.max(1))) { + if !p.is_finite() { + continue; + } + k += 1.0; + acc = acc + *p * k; + wsum += k; + } + if wsum <= 1e-6 { last } else { acc * (1.0 / wsum) } +} + +/// Adds a sample at time `now`. The stored point is the stabilized one, which is also what +/// the preview draws, so the ink on screen is the ink that lands. pub fn sample(app: &mut App, p: Vec2, pressure: Option, xf: Xf, now: f64) { - if pressure.is_some() { - app.pen_pressure = pressure; - } else if options(app).velocity - && matches!(app.tool, Tool::Brush | Tool::PaintBrush) - && let Some((last, _)) = app.brush_samples.last() - { - let moved = last.distance(p) * xf.zoom; - if moved < 0.5 { - return; + let prev = app.brush_hand.last().copied().unwrap_or(p); + let moved = prev.distance(p) * xf.zoom; + let (velocity, smoothing) = { + let brush = options(app); + (brush.velocity, brush.smoothing) + }; + if matches!(app.tool, Tool::Brush | Tool::PaintBrush) { + let pressure = pressure.filter(|n| n.is_finite()).map(|n| n.clamp(0.0, 1.0)); + if let Some(pressure) = pressure { + if !app.brush_from_pen && (now - app.brush_down) <= PEN_OPEN_SECS { + stamp_pressure(&mut app.brush_samples, pressure); + } + app.brush_from_pen = true; + app.pen_pressure = Some(pressure); + } else if !app.brush_from_pen && velocity && moved >= 0.5 { + let follow = if smoothing <= 1e-4 { 1.0 } else { 0.3 }; + let raw_dt = (now - app.brush_clock) as f32; + if raw_dt > 1e-4 { + let opening = app.brush_open_time == 0.0 || app.brush_open_dist < OPENING_PTS; + app.brush_open_dist += moved; + app.brush_open_time += raw_dt; + if opening { + if let Some(target) = velocity_target(app.brush_open_dist, app.brush_open_time) { + app.pen_pressure = Some(target); + stamp_pressure(&mut app.brush_samples, target); + } + } else { + app.pen_pressure = Some(velocity_pressure(app.pen_pressure.unwrap_or(VELOCITY_START), moved, raw_dt, follow)); + } + app.brush_clock = now; + } else if app.brush_open_time == 0.0 && app.pen_pressure.is_none() { + let dt = 1.0 / 60.0; + app.brush_open_dist += moved; + app.brush_open_time += dt; + if let Some(target) = velocity_target(app.brush_open_dist, app.brush_open_time) { + app.pen_pressure = Some(target); + stamp_pressure(&mut app.brush_samples, target); + } + app.brush_clock = now; + } } - app.pen_pressure = Some(velocity_pressure(app.pen_pressure.unwrap_or(VELOCITY_START), moved, (now - app.brush_clock) as f32)); - app.brush_clock = now; } + if prev.distance(p) > 1e-4 { + app.brush_hand.push(p); + if app.brush_hand.len() > HAND { + let extra = app.brush_hand.len() - HAND; + app.brush_hand.drain(0..extra); + } + } + let placed = if app.brush_hand.is_empty() { p } else { stabilize(&app.brush_hand, smoothing) }; let pr = app.pen_pressure; - let far = app.brush_samples.last().is_none_or(|(l, lp)| l.distance(p) * xf.zoom >= 0.5 || (pr.unwrap_or(1.0) - lp.unwrap_or(1.0)).abs() > 0.05); + let min_change = if smoothing <= 1e-4 { 0.01 } else { 0.05 }; + let far = app.brush_samples.last().is_none_or(|(l, lp)| l.distance(placed) * xf.zoom >= 0.5 || (pr.unwrap_or(1.0) - lp.unwrap_or(1.0)).abs() > min_change); if far && app.brush_samples.len() < anicraft_engine::geom::brush::MAX_SAMPLES { - app.brush_samples.push((p, pr)); + app.brush_samples.push((placed, pr)); } } /// Sends the stroke (one command, one undo step). Smoothing and accuracy follow the screen. pub fn commit(app: &mut App, xf: Xf) { let samples = std::mem::take(&mut app.brush_samples); + app.brush_hand.clear(); if samples.is_empty() { return; } @@ -293,22 +428,30 @@ pub fn commit(app: &mut App, xf: Xf) { params["unit"] = json!(xf.unit()); params["as_stroke"] = json!(as_stroke); if as_stroke { - if let Some(v) = app.draw("draw.brush", params) - && v["width_points"].as_u64().is_some_and(|n| n > 1) - { - app.status_ok(tr!("A line whose width you can shape with the Width tool.")); + if let Some(v) = app.draw("draw.brush", params) { + app.brush_latch = samples; + app.brush_latch_erase = false; + app.brush_latch_state = Some(app.s.stage_picture().state); + if v["width_points"].as_u64().is_some_and(|n| n > 1) { + app.status_ok(tr!("A line whose width you can shape with the Width tool.")); + } } return; } // Paint Selection keeps the selection it paints in, for the next stroke (ADR 0080). let v = if o.mode == PaintMode::Selection { app.run("draw.brush", params) } else { app.draw("draw.brush", params) }; - if let Some(v) = v - && v["elements"].as_array().is_some_and(Vec::is_empty) - { - match o.mode { - PaintMode::Selection => app.status_ok(tr!("Nothing painted: Paint Selection paints only inside the selected fills. Select them with the Selection tool first.")), - PaintMode::Inside => app.status_ok(tr!("Nothing painted: Paint Inside paints only inside the fill where the stroke starts.")), - _ => app.status_ok(tr!("Nothing to paint behind there: Paint Behind only paints empty areas.")), + if let Some(v) = v { + let empty = v["elements"].as_array().is_some_and(Vec::is_empty); + if empty { + match o.mode { + PaintMode::Selection => app.status_ok(tr!("Nothing painted: Paint Selection paints only inside the selected fills. Select them with the Selection tool first.")), + PaintMode::Inside => app.status_ok(tr!("Nothing painted: Paint Inside paints only inside the fill where the stroke starts.")), + _ => app.status_ok(tr!("Nothing to paint behind there: Paint Behind only paints empty areas.")), + } + } else { + app.brush_latch = samples; + app.brush_latch_erase = false; + app.brush_latch_state = Some(app.s.stage_picture().state); } } } @@ -357,6 +500,9 @@ pub fn draw_overlay(app: &App, painter: &egui::Painter, xf: Xf, hover: Option)], color: egui::Color32) { if samples.is_empty() { return; @@ -376,14 +525,30 @@ pub fn draw_sweep(painter: &egui::Painter, xf: Xf, o: &BrushOptions, samples: &[ }; let step = samples.len().div_ceil(MAX_PREVIEW).max(1); let picked: Vec<&(Vec2, Option)> = samples.iter().step_by(step).chain(samples.last()).collect(); + let mut mesh = egui::Mesh::default(); + let mut fan = |pts: Vec| { + if pts.len() < 3 || mesh.vertices.len().saturating_add(pts.len()) >= u32::MAX as usize { + return; + } + let base = mesh.vertices.len() as u32; + for p in &pts { + mesh.colored_vertex(*p, color); + } + for i in 1..pts.len() - 1 { + mesh.add_triangle(base, base + i as u32, base + (i as u32) + 1); + } + }; if let [only] = picked.as_slice() { - painter.add(egui::Shape::convex_polygon(hull(screen(only.0, only.1)), color, Stroke::NONE)); + fan(hull(screen(only.0, only.1))); } for pair in picked.windows(2) { let &[a, b] = pair else { continue }; let mut pts = screen(a.0, a.1); pts.extend(screen(b.0, b.1)); - painter.add(egui::Shape::convex_polygon(hull(pts), color, Stroke::NONE)); + fan(hull(pts)); + } + if !mesh.indices.is_empty() { + painter.add(egui::Shape::mesh(mesh)); } } @@ -422,25 +587,29 @@ mod tests { assert_eq!(o.preset("Ink Pen"), ink); o.size += 1.0; assert!(!o.matches(&ink), "changed since"); - // The defaults are the plain brush's (ADR 0033). - assert!(BrushOptions::default().matches(&BrushPreset::default())); + // The tool opens raw. A preset's own smoothing is what `matches` compares. + assert_eq!(BrushOptions::default().smoothing, 0.0); + assert_eq!(BrushOptions::default().min_size, 0.01); + assert!(BrushOptions::default().velocity); + let plain = BrushOptions { smoothing: BrushPreset::default().smoothing, ..BrushOptions::default() }; + assert!(plain.matches(&BrushPreset::default())); assert_eq!(BrushOptions::default().size, 10.0); } #[test] fn speed_stands_in_for_pressure() { - // Still: it rises toward full; fast: it falls toward a fifth; never outside 0..1. + // Still: it rises toward full. Fast: it falls toward the thinnest, not a fifth. let mut p = VELOCITY_START; for _ in 0..30 { - p = velocity_pressure(p, 1.0, 0.1); + p = velocity_pressure(p, 1.0, 0.1, 0.3); } assert!(p > 0.95, "{p}"); - for _ in 0..30 { - p = velocity_pressure(p, 500.0, 0.016); + for _ in 0..40 { + p = velocity_pressure(p, 500.0, 0.016, 0.3); } - assert!((p - 0.2).abs() < 0.02, "{p}"); - assert_eq!(velocity_pressure(0.4, 10.0, 0.0), 0.4, "no time passed"); - assert_eq!(velocity_pressure(f32::NAN, f32::INFINITY, 1.0), VELOCITY_START); + assert!(p < 0.05, "{p}"); + assert_eq!(velocity_pressure(0.4, 10.0, 0.0, 0.3), 0.4, "no time passed"); + assert_eq!(velocity_pressure(f32::NAN, f32::INFINITY, 1.0, 0.3), VELOCITY_START); } #[test] diff --git a/src/erasertool.rs b/src/erasertool.rs index 706afe28..4c801c17 100644 --- a/src/erasertool.rs +++ b/src/erasertool.rs @@ -40,6 +40,8 @@ impl EraserOptions { /// Starts an eraser stroke at stage point `p` (the eraser ignores pen pressure). pub fn start(app: &mut App, p: Vec2) -> Drag { app.pen_pressure = None; + app.brush_from_pen = false; + app.brush_hand.clear(); app.brush_samples = vec![(p, None)]; Drag::Eraser } @@ -57,10 +59,15 @@ pub fn commit(app: &mut App, xf: Xf) { // in place with a double-click so their own lines and fills can be erased. let kept = |v: &serde_json::Value| (v["kept"]["symbols"].as_u64().unwrap_or(0), v["kept"]["objects"].as_u64().unwrap_or(0)); match app.run("draw.erase", params) { - Some(v) if v["erased"] == true => match kept(&v) { - (0, 0) => app.status_ok(tr!("Erased. {undo} to undo.", undo = undo)), - _ => app.status_ok(tr!("Erased the lines and fills. {undo} to undo. Symbols and drawing objects are kept: double-click one with the Selection tool to erase inside it.", undo = undo)), - }, + Some(v) if v["erased"] == true => { + app.brush_latch = samples; + app.brush_latch_erase = true; + app.brush_latch_state = Some(app.s.stage_picture().state); + match kept(&v) { + (0, 0) => app.status_ok(tr!("Erased. {undo} to undo.", undo = undo)), + _ => app.status_ok(tr!("Erased the lines and fills. {undo} to undo. Symbols and drawing objects are kept: double-click one with the Selection tool to erase inside it.", undo = undo)), + } + } Some(v) if kept(&v) != (0, 0) => { let k = kept(&v); app.status_ok(match k { @@ -105,6 +112,9 @@ pub fn draw_overlay(app: &App, painter: &egui::Painter, xf: Xf, hover: Option4.0}%", z * 100.0))).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("zoom").width(96.0).selected_text(text(Type::Data, format!("{:>4.0}%", z * 100.0))).show_ui(ui, |ui| { if ui.selectable_label(false, tr!("Fit in window")).clicked() { app.view.fit = true; } - for pct in [25.0f32, 50.0, 100.0, 200.0, 400.0, 800.0] { + // Through 25600 %, the view's own limit (ADR 0096): a hairline is drawable once you can see it. + for pct in [25.0f32, 50.0, 100.0, 200.0, 400.0, 800.0, 1_600.0, 3_200.0, 6_400.0, 12_800.0, 25_600.0] { if ui.selectable_label((z * 100.0 - pct).abs() < 0.5, text(Type::Data, format!("{pct:>4.0}%"))).clicked() { app.zoom_about(canvas, canvas.center(), pct / 100.0); } @@ -2723,23 +2724,37 @@ fn eraser_props(app: &mut App, ui: &mut egui::Ui) { /// and shaping. The tip's shape and the paint mode are the Brush's only (the Paint Brush draws /// a round line). fn brush_props(app: &mut App, ui: &mut egui::Ui) { - crate::brush_panel::props(app, ui, |ui, l, f| row(ui, l, |ui| f(ui)), |ui, l, f| row_prose(ui, l, |ui| f(ui))); let paint_brush = app.tool == Tool::PaintBrush; use crate::brushtool::{SIZE_MAX, SIZE_MIN}; use anicraft_engine::cmds_brush::PaintMode; use anicraft_engine::geom::brush::TipShape; + { + let b = crate::brushtool::options_mut(app); + // A number, not a slider: the slider's own value sits outside the row and runs past a + // 200 pt dock once Off is translated (ADR 0087, ADR 0096). Off is the raw-ink stop. + row(ui, tr!("Smoothing"), |ui| { + if ui.selectable_label(b.smoothing <= 0.5, tr!("Off")).on_hover_text(tr!("Off keeps every point of the stroke. Nothing fits a curve through it.")).clicked() { + b.smoothing = 0.0; + } + ui.add(egui::DragValue::new(&mut b.smoothing).speed(0.5).range(0.0..=100.0).max_decimals(0)).named(tr!("Brush smoothing")); + }); + } + crate::brush_panel::props(app, ui, |ui, l, f| row(ui, l, |ui| f(ui)), |ui, l, f| row_prose(ui, l, |ui| f(ui))); let keys = [app.keymap.primary("view.brush_smaller"), app.keymap.primary("view.brush_larger")].into_iter().flatten().map(crate::platform::shortcut_label).collect::>().join(" / "); let b = crate::brushtool::options_mut(app); - row(ui, tr!("Size"), |ui| { - let r = ui.add(egui::DragValue::new(&mut b.size).suffix(" px").speed(0.25).range(SIZE_MIN..=SIZE_MAX).max_decimals(1)).named(tr!("Brush size")); + row(ui, tr!("Thinnest"), |ui| { + ui.add(egui::DragValue::new(&mut b.min_size).suffix(" px").speed(0.05).range(SIZE_MIN..=b.size).max_decimals(2)).named(tr!("Thinnest brush size")).on_hover_text(tr!("The width at no pressure. A light touch is this wide.")); + }); + row(ui, tr!("Max"), |ui| { + let r = ui.add(egui::DragValue::new(&mut b.size).suffix(" px").speed(0.25).range(SIZE_MIN..=SIZE_MAX).max_decimals(2)).named(tr!("Brush size")); + if b.min_size > b.size { + b.min_size = b.size; + } if !keys.is_empty() { - r.on_hover_text(tr!("Brush size ({keys})", keys = keys)); + r.on_hover_text(tr!("The width at full pressure ({keys})", keys = keys)); } }); if paint_brush { - row(ui, tr!("Smoothing"), |ui| { - ui.add(egui::DragValue::new(&mut b.smoothing).speed(0.5).range(0.0..=100.0).max_decimals(0)).named(tr!("Brush smoothing")).on_hover_text(tr!("How much the stroke is smoothed: 0 follows the hand closely")); - }); return; } row(ui, tr!("Shape"), |ui| { @@ -2753,9 +2768,6 @@ fn brush_props(app: &mut App, ui: &mut egui::Ui) { row(ui, tr!("Roundness"), |ui| { ui.add(egui::DragValue::new(&mut b.roundness).suffix(" %").speed(0.5).range(5.0..=100.0).max_decimals(0)).named(tr!("Brush roundness")).on_hover_text(tr!("100 %: as wide as it is tall. Less flattens the tip, for calligraphic strokes")); }); - row(ui, tr!("Smoothing"), |ui| { - ui.add(egui::DragValue::new(&mut b.smoothing).speed(0.5).range(0.0..=100.0).max_decimals(0)).named(tr!("Brush smoothing")).on_hover_text(tr!("How much the stroke is smoothed: 0 follows the hand closely")); - }); row_prose(ui, tr!("Mode"), |ui| { egui::ComboBox::from_id_salt("brush_mode").width(crate::theme::fit(ui, 170.0)).selected_text(crate::i18n::t(b.mode.label())).show_ui(ui, |ui| { for m in PaintMode::ALL { diff --git a/src/platform.rs b/src/platform.rs index dc4d30a6..7295c8ff 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -722,6 +722,20 @@ thread_local! { static MAC_INBOX: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } +/// Stylus pressure this frame, `0.0..=1.0`, when this system has a tip-down tablet point +/// (ADR 0096). macOS is the only platform that reports one here. A pen that arrives as a touch +/// is read from egui by the brush. A plain mouse is `None`. +pub fn pen_pressure() -> Option { + #[cfg(target_os = "macos")] + { + anicraft_pen_macos::current() + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + /// Listens for documents the system hands over while AniCraft runs. Keep the result until the /// event loop returns. On macOS it must start before the event loop does, so the document that /// launched the app (sent as an Apple Event, not on the command line) isn't missed. Windows and @@ -738,6 +752,8 @@ pub fn listen_for_documents() -> DocumentListener { { let (registration, inbox) = fmv_macos_events::Registration::install(); MAC_INBOX.with(|i| *i.borrow_mut() = Some(inbox)); + // Before the first pen event, so a stroke that starts on the first frame still has it. + anicraft_pen_macos::install(); DocumentListener { _registration: registration } } #[cfg(not(target_os = "macos"))] diff --git a/src/rulers.rs b/src/rulers.rs index 7d0d9279..81ba50c7 100644 --- a/src/rulers.rs +++ b/src/rulers.rs @@ -278,7 +278,7 @@ pub fn paint_guides(app: &App, painter: &Painter, geo: Geo, ppp: f32) { /// The step between labelled ticks: the first that leaves room for a number on screen. fn major_step(zoom: f32) -> f32 { - const STEPS: [f32; 17] = [1.0, 2.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0, 200.0, 250.0, 500.0, 1_000.0, 2_000.0, 2_500.0, 5_000.0, 10_000.0, 20_000.0]; + const STEPS: [f32; 19] = [0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0, 200.0, 250.0, 500.0, 1_000.0, 2_000.0, 2_500.0, 5_000.0, 10_000.0, 20_000.0]; STEPS.into_iter().find(|s| s * zoom >= 56.0).unwrap_or(50_000.0) } @@ -411,7 +411,7 @@ mod tests { #[test] fn ruler_ticks_stay_readable_at_every_zoom() { - for zoom in [0.05f32, 0.25, 0.5, 1.0, 3.0, 16.0, 64.0] { + for zoom in [0.05f32, 0.25, 0.5, 1.0, 3.0, 16.0, 64.0, 256.0] { let major = major_step(zoom); assert!(major * zoom >= 56.0 || major == 50_000.0, "zoom {zoom}"); let minor = major / minor_count(major, zoom); diff --git a/src/stage.rs b/src/stage.rs index 693a6969..a84bb29b 100644 --- a/src/stage.rs +++ b/src/stage.rs @@ -207,6 +207,28 @@ fn to_v(p: Pos2) -> Vec2 { Vec2::new(p.x, p.y) } +/// Every pointer position this frame, oldest first. One position per frame cuts the corners +/// out of a fast stroke, so the line that lands is smoother than the hand (ADR 0096). +fn paint_moves(app: &mut App, ui: &egui::Ui, xf: crate::pathtools::Xf, pressure: Option, now: f64) { + let moves = ui.input(|i| { + let mut pts: Vec = i.events.iter().filter_map(|e| match e { egui::Event::PointerMoved(p) => Some(*p), _ => None }).collect(); + if pts.is_empty() + && let Some(p) = i.pointer.interact_pos() + { + pts.push(p); + } + pts + }); + let n = moves.len(); + let t0 = app.brush_clock; + let span = now - t0; + for (i, p) in moves.into_iter().enumerate() { + // Share the frame's time across its events, so each one's speed is its own. + let t = if n > 1 && span > 1e-4 { t0 + span * ((i + 1) as f64 / n as f64) } else { now }; + crate::brushtool::sample(app, xf.to_stage(p), pressure, xf, t); + } +} + pub fn show(app: &mut App, ui: &mut egui::Ui) { // View › Rulers along the top and the left (ADR 0076); the stage view in what is left. let (canvas, bars) = crate::rulers::layout(app, ui.max_rect()); @@ -230,7 +252,7 @@ pub fn show(app: &mut App, ui: &mut egui::Ui) { app.view.fit = true; } if app.view.fit && canvas.width() > 10.0 && canvas.height() > 10.0 { - app.view.zoom = ((canvas.width() - 64.0) / size.x).min((canvas.height() - 64.0) / size.y).clamp(0.05, 64.0); + app.view.zoom = ((canvas.width() - 64.0) / size.x).min((canvas.height() - 64.0) / size.y).clamp(0.05, 256.0); app.view.pan = egui::Vec2::ZERO; app.view.fit = false; app.view.fitted = Some((vec2(canvas.width(), canvas.height()), app.view.zoom)); @@ -654,8 +676,8 @@ pub fn show(app: &mut App, ui: &mut egui::Ui) { crate::shape_envelope::drag_to(app, d, to_v(c)); } (Drag::Pencil, Some(c)) => crate::pathtools::pencil_sample(app, to_v(c), xf), - (Drag::Brush, Some(c)) => crate::brushtool::sample(app, to_v(c), pressure, xf, now), - (Drag::Eraser, Some(c)) => crate::brushtool::sample(app, to_v(c), None, xf, now), + (Drag::Brush, _) => paint_moves(app, ui, xf, pressure, now), + (Drag::Eraser, _) => paint_moves(app, ui, xf, None, now), (Drag::Marquee { cur: m, .. }, Some(c)) => *m = c, (Drag::Shape { start, cur: m }, Some(c)) => *m = constrain(app.tool, *start, c, shift), _ => {} @@ -1190,6 +1212,15 @@ pub fn show(app: &mut App, ui: &mut egui::Ui) { crate::widthtool::draw_overlay(app, &painter, xf, pointer.filter(|p| canvas.contains(*p))); crate::gradient_tool::draw_overlay(app, &painter, xf, pointer.filter(|p| canvas.contains(*p) && !panning)); crate::shape_envelope::draw_overlay(app, &painter, xf, pointer.filter(|p| canvas.contains(*p) && !panning)); + if app.brush_latch_state.is_some() { + let shown = app.s.stage_picture().state; + let on_screen = app.stage_cache.shown_state == Some(shown); + if on_screen || app.brush_latch_state != Some(shown) { + app.brush_latch.clear(); + app.brush_latch_state = None; + app.brush_latch_erase = false; + } + } crate::brushtool::draw_overlay(app, &painter, xf, pointer.filter(|p| canvas.contains(*p) && !panning && !paused)); crate::erasertool::draw_overlay(app, &painter, xf, pointer.filter(|p| canvas.contains(*p) && !panning && !paused)); // After this frame's edits, so a drag on the path shows where it is now.