From cb5435d5ffcdc301ea99c29dda40674396f78572 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 13:43:48 +0700 Subject: [PATCH] M4: stretch and scatter brushes, pen pressure (#120) Strokes can use a brush: eight presets drawn in code (four stretched along the path, four scattered with spacing, size, rotation and offset jitter from a fixed seed), drawn by the shared stroke-outline code in canvas, PNG, SVG, PDF and outline stroke, with caps on copies and steps. Pointer events carry pen pressure; the Pencil records it as the stroke's width profile, which brushes follow (Windows pens and browser styluses; constant width elsewhere). --- Cargo.lock | 1 + ROADMAP.md | 11 +- apps/figcraft-cli/src/perf.rs | 15 ++- crates/doc/src/props.rs | 59 ++++++++- crates/engine/src/brush_tests.rs | 109 +++++++++++++++ crates/engine/src/cmd/edit.rs | 23 ++++ crates/engine/src/cmd/props.rs | 19 ++- crates/engine/src/cmd/view.rs | 14 ++ crates/engine/src/devfocus_tests.rs | 2 +- crates/engine/src/inspect.rs | 3 + crates/engine/src/lib.rs | 2 + crates/engine/src/tools.rs | 66 +++++++-- crates/mcp/src/headless.rs | 13 +- crates/mcp/src/tools.rs | 7 + crates/pathops/src/brush.rs | 199 ++++++++++++++++++++++++++++ crates/pathops/src/brush_tests.rs | 74 +++++++++++ crates/pathops/src/lib.rs | 12 +- crates/render/src/draw.rs | 5 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 32 ++++- crates/ui-egui/src/control.rs | 9 +- crates/ui-egui/src/right.rs | 107 +++++++++++++++ docs/control-protocol.md | 5 +- docs/gaps.md | 16 ++- docs/hardware-parity.md | 5 +- docs/mcp.md | 21 ++- docs/roadmap.md | 7 +- docs/target-app-parity.md | 17 +-- docs/ui-parity.md | 4 +- 29 files changed, 802 insertions(+), 56 deletions(-) create mode 100644 crates/engine/src/brush_tests.rs create mode 100644 crates/pathops/src/brush.rs create mode 100644 crates/pathops/src/brush_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 7cecba2..44ad5ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,6 +1665,7 @@ dependencies = [ "figcraft-engine", "figcraft-geom", "figcraft-layout", + "figcraft-pathops", "figcraft-render", "figcraft-text", "image", diff --git a/ROADMAP.md b/ROADMAP.md index 5ebe191..736784d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~200–365 h** one agent | estimated | -| Remaining to full parity | **~378–722 h** one agent (~95–165 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~373–712 h** one agent (~95–165 h wall-clock with 5 agents) | estimated | ### Readiness by audience @@ -62,7 +62,7 @@ the localization layer with Figma's own languages (20–35 h). | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | -| Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) | +| Hardware | 55% | 13–27 | [hardware-parity](docs/hardware-parity.md) | | Localization | 10% (English only) | 60–100 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 20% (own WASM plugins) | 25–55 | [gaps](docs/gaps.md) | @@ -77,7 +77,7 @@ the localization layer with Figma's own languages (20–35 h). | Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) | | Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) | -| Strokes | 90% | 72% | 2–4 | [target-app-parity](docs/target-app-parity.md) | +| Strokes | 90% | 72% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) | @@ -91,7 +91,7 @@ the localization layer with Figma's own languages (20–35 h). | Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 35% | 20% | 25–55 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | -| Figma Draw extras | 65% | 45% | 5–10 | [gaps](docs/gaps.md) | +| Figma Draw extras | 80% | 55% | 2–5 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | ## Languages @@ -171,6 +171,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 1bbee2a..7c23b37 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -175,8 +175,19 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> { "hover pointer move (µs each)", mean(hits, |i| { let p = pt(i); - let ev = - PointerEvent { kind: PointerKind::Hover, x: p.x, y: p.y, sx: 0.0, sy: 0.0, shift: false, alt: false, cmd: false, ctrl: false, zoom }; + let ev = PointerEvent { + kind: PointerKind::Hover, + x: p.x, + y: p.y, + sx: 0.0, + sy: 0.0, + shift: false, + alt: false, + cmd: false, + ctrl: false, + zoom, + pressure: None, + }; let _ = s.pointer(&ev); }) * 1000.0, ); diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 5dd8ff6..a08f614 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -81,6 +81,59 @@ pub struct StrokeProps { /// Variable width: how the width changes along the path (`None` = uniform). #[serde(skip_serializing_if = "Option::is_none")] pub width_profile: Option, + /// A brush the stroke is drawn with instead of a plain band (`None`: plain). + #[serde(skip_serializing_if = "Option::is_none")] + pub brush: Option, +} + +/// A brush stroke: the stroke drawn with a built-in brush shape (`preset`, an id of +/// `figcraft_pathops::brush::PRESETS`), stretched along the path or scattered along it every +/// `spacing` stroke weights with repeatable jitter from `seed`. A width profile scales it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Brush { + pub preset: String, + /// Scatter: distance between copies, in stroke weights. + pub spacing: f64, + /// Scatter: how much copies vary in size, 0..1. + pub size_jitter: f64, + /// Scatter: how far copies turn from the path's direction, 0..1 (1: up to half a turn). + pub rotation_jitter: f64, + /// Scatter: how far copies stray from the path, in stroke weights, 0..4. + pub scatter: f64, + pub seed: u32, +} + +impl Default for Brush { + fn default() -> Self { + Self { preset: String::new(), spacing: 1.5, size_jitter: 0.0, rotation_jitter: 0.0, scatter: 0.0, seed: 0 } + } +} + +impl Brush { + /// A brush of `preset` with default settings. + pub fn new(preset: impl Into) -> Self { + Self { preset: preset.into(), ..Self::default() } + } + + /// Every value in range (NaN: the default). + pub fn sanitized(&self) -> Self { + let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d }; + Self { + preset: self.preset.clone(), + spacing: c(self.spacing, 0.1, 100.0, 1.5), + size_jitter: c(self.size_jitter, 0.0, 1.0, 0.0), + rotation_jitter: c(self.rotation_jitter, 0.0, 1.0, 0.0), + scatter: c(self.scatter, 0.0, 4.0, 0.0), + seed: self.seed, + } + } + + /// How much wider than the stroke weight the brush can paint (larger copies, strays). + pub fn reach_factor(&self) -> f64 { + let b = self.sanitized(); + 1.0 + b.size_jitter + 2.0 * b.scatter + } } impl Default for StrokeProps { @@ -95,6 +148,7 @@ impl Default for StrokeProps { miter_limit: 4.0, dashes: Vec::new(), width_profile: None, + brush: None, } } } @@ -105,9 +159,10 @@ impl StrokeProps { self.width_profile.as_ref().filter(|p| !p.points.is_empty()) } - /// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile). + /// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile + /// and widened by a brush's reach). pub fn max_weight(&self) -> f64 { - self.profile().map_or(self.weight, |p| self.weight * p.max_scale()) + self.profile().map_or(self.weight, |p| self.weight * p.max_scale()) * self.brush.as_ref().map_or(1.0, Brush::reach_factor) } } diff --git a/crates/engine/src/brush_tests.rs b/crates/engine/src/brush_tests.rs new file mode 100644 index 0000000..e4e8b42 --- /dev/null +++ b/crates/engine/src/brush_tests.rs @@ -0,0 +1,109 @@ +//! Brushes and pen pressure through the session: `stroke.brush`, rendering and outlining brush +//! strokes, and the Pencil recording pressure (and its brush) into the stroke it draws. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind, pressure_profile}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_geom::Point; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// A 200-long horizontal vector with a 10 px black stroke. +fn line(s: &mut Session) -> NodeId { + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 0, "y": 0})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}})).unwrap(); + v +} + +/// The alpha of the page's layers at page point (`x`, `y`), rendered at 1x without the page colour. +fn alpha(s: &Session, x: f64, y: f64) -> u8 { + let doc = s.doc().unwrap(); + let view = figcraft_render::View { origin: (x - 1.0, y - 1.0), scale: 1.0, width: 3, height: 3 }; + let mut pix = figcraft_render::Pixmap::new(3, 3).unwrap(); + figcraft_render::render_nodes_into(doc, &doc.node(s.page().unwrap()).unwrap().children, &view, &mut pix); + pix.data()[(3 + 1) * 4 + 3] +} + +#[test] +fn brushes_draw_along_the_stroke_and_outline_to_vectors() { + let mut s = Session::new(); + let v = line(&mut s); + assert_eq!(alpha(&s, 15.0, 0.0), 255, "a plain stroke covers the line"); + assert!(s.execute("stroke.brush", &json!({"ids": [v], "preset": "glitter"})).is_err()); + // Dots every 2 weights: centres at 10, 30, 50 … with gaps between. + s.execute("stroke.brush", &json!({"ids": [v], "preset": "dots", "spacing": 2})).unwrap(); + assert_eq!(alpha(&s, 10.0, 0.0), 255); + assert_eq!(alpha(&s, 20.0, 0.0), 0, "the gap between two dots"); + let b = s.execute("node.get", &json!({"id": v})).unwrap()["brush"].clone(); + assert_eq!((b["preset"].as_str(), b["spacing"].as_f64()), (Some("dots"), Some(2.0)), "{b}"); + // A stretch brush tapers to a point at both ends. + s.execute("stroke.brush", &json!({"ids": [v], "preset": "taper"})).unwrap(); + assert_eq!(alpha(&s, 100.0, 2.0), 255); + assert_eq!(alpha(&s, 2.0, 3.0), 0); + // Outline stroke turns the brush into a filled vector. + s.execute("vector.outlineStroke", &json!({"ids": [v]})).unwrap(); + let doc = s.doc().unwrap(); + let outlined = doc.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. }) && !n.fills.is_empty()).expect("an outlined vector"); + assert!(outlined.strokes.is_empty() || outlined.stroke.brush.is_none()); + assert_eq!(alpha(&s, 100.0, 2.0), 255, "looks the same after outlining"); + // Removing the brush: a plain stroke again. + let mut s = Session::new(); + let v = line(&mut s); + s.execute("stroke.brush", &json!({"ids": [v], "preset": "dots", "spacing": 2})).unwrap(); + s.execute("stroke.brush", &json!({"ids": [v], "preset": null})).unwrap(); + assert_eq!(alpha(&s, 20.0, 0.0), 255); + // Exports draw it too. + s.execute("stroke.brush", &json!({"ids": [v], "preset": "stars"})).unwrap(); + let doc = s.doc().unwrap(); + assert!(figcraft_svg::export_svg(doc, &[v], &figcraft_svg::SvgOptions::default()).unwrap().contains(" = (0..=10).map(|i| Point::new(f64::from(i) * 10.0, 0.0)).collect(); + assert!(pressure_profile(&pts, &[None; 11]).is_none(), "a mouse: a plain stroke"); + let light_to_hard: Vec> = (0..=10).map(|i| Some(i as f32 / 10.0)).collect(); + let prof = pressure_profile(&pts, &light_to_hard).unwrap(); + assert_eq!(prof.len(), 11); + assert_eq!(prof.first(), Some(&[0.0, 0.1]), "no pressure still draws a hairline"); + assert_eq!(prof.get(5), Some(&[0.5, 1.0]), "medium pressure: the stroke weight"); + assert_eq!(prof.last(), Some(&[1.0, 2.0])); + assert!(pressure_profile(&[Point::ZERO, Point::ZERO], &[Some(0.5), Some(0.5)]).is_none(), "no length"); +} + +#[test] +fn the_pencil_records_pressure_and_its_brush() { + let mut s = Session::new(); + s.execute("tool.set", &json!({"tool": "pencil"})).unwrap(); + assert!(s.execute("tool.pencilBrush", &json!({"preset": "nope"})).is_err()); + s.execute("tool.pencilBrush", &json!({"preset": "taper"})).unwrap(); + let ev = |kind, x: f64, p: f32| PointerEvent { pressure: Some(p), ..PointerEvent::at(kind, x, 50.0 + (x / 20.0).sin() * 10.0, 1.0) }; + s.pointer(&ev(PointerKind::Down, 0.0, 0.2)).unwrap(); + for i in 1..=40 { + s.pointer(&ev(PointerKind::Move, f64::from(i) * 5.0, 0.2 + 0.02 * i as f32)).unwrap(); + } + s.pointer(&ev(PointerKind::Up, 200.0, 1.0)).unwrap(); + let doc = s.doc().unwrap(); + let n = doc.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).expect("a pencil stroke"); + let prof = n.stroke.profile().expect("pressure recorded"); + assert!(prof.scale_at(0.0) < 0.5 && prof.scale_at(1.0) > 1.9, "light to hard: {prof:?}"); + assert_eq!(n.stroke.brush.as_ref().map(|b| b.preset.as_str()), Some("taper")); + // A mouse (no pressure) draws a plain uniform stroke. + s.execute("tool.pencilBrush", &json!({"preset": null})).unwrap(); + s.execute("edit.selectAll", &json!({})).ok(); + let before = s.doc().unwrap().nodes().count(); + s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 200.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 50.0, 220.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 200.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 200.0, 1.0)).unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(doc.nodes().count(), before + 1); + let last = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap(); + assert!(last.stroke.profile().is_none() && last.stroke.brush.is_none()); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8737e32..e3d3b66 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -223,6 +223,13 @@ pub fn specs() -> Vec { true, width_profile ), + spec!( + "stroke.brush", + "Brush", + "{ids?, preset (taper|ink|ribbon|bristle: stretched along the path; dots|stars|confetti|leaves: scattered along it | null: plain), spacing? (× weight), sizeJitter? (0-1), rotationJitter? (0-1), scatter? (0-4 × weight), seed?} layers without a stroke get a black one", + true, + stroke_brush + ), spec!( "effect.add", "Add effect", @@ -987,6 +994,22 @@ fn width_profile(s: &mut Session, p: &Value) -> Result { ok() } +fn stroke_brush(s: &mut Session, p: &Value) -> Result { + let cmd = "stroke.brush"; + let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; + let brush = super::props::brush_of(&v).ok_or_else(|| bad(cmd, super::props::BRUSH_HELP))?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + if brush.is_some() && n.strokes.is_empty() { + n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); + } + n.stroke.brush = brush.clone(); + } + ok() +} + fn reparent(s: &mut Session, p: &Value) -> Result { let ids = req_targets("node.reparent", s, p)?; let parent = req_id("node.reparent", p, "parent")?; diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 885473d..63429c8 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -11,7 +11,7 @@ use crate::{Result, bad}; /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \ fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \ -strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ +strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap, \ columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \ @@ -131,6 +131,20 @@ fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Resul pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)"; +pub(crate) const BRUSH_HELP: &str = "brush: a preset (taper|ink|ribbon|bristle: stretched; dots|stars|confetti|leaves: scattered), {preset, spacing? (× weight), sizeJitter? (0-1), rotationJitter? (0-1), scatter? (0-4 × weight), seed?}, or null (a plain stroke)"; + +/// A brush from a preset id, `{preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?}` +/// or null. `Some(None)` = no brush; `None` = not a brush (or an unknown preset). +pub fn brush_of(v: &Value) -> Option> { + let b = match v { + Value::Null => return Some(None), + Value::String(id) => Brush::new(id.as_str()), + Value::Object(_) => serde_json::from_value::(snake(v)).ok()?, + _ => return None, + }; + figcraft_pathops::brush::preset(&b.preset).map(|_| Some(b.sanitized())) +} + /// A width profile from a preset name, points (`[[t, scale] | [t, left, right], ...]` or /// `[{t, scale} | {t, left, right}]`), `{preset}` / `{points}`, or null. `Some(None)` = uniform; /// `None` = not a profile. @@ -342,6 +356,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, + "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, "cornerRadius" => { let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?; doc.get_mut(id)?.corners.radii = r.map(|x| x.max(0.0)); @@ -689,7 +704,7 @@ pub fn override_group(k: &str) -> &'static str { match k { "fills" | "fill" => "fills", "strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" - | "widthProfile" => "strokes", + | "widthProfile" | "brush" | "strokeBrush" => "strokes", "effects" => "effects", "opacity" | "blend" | "blendMode" => "opacity", "visible" => "visible", diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 42de680..57ba778 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -258,6 +258,20 @@ pub fn specs() -> Vec { tool(s, t) } ), + spec!( + "tool.pencilBrush", + "Pencil brush", + "{preset? (taper|ink|ribbon|bristle|dots|stars|confetti|leaves | null: plain), spacing?, sizeJitter?, rotationJitter?, scatter?, seed?} (the brush new Pencil strokes get; omit `preset` to read it)", + false, + |s, p| { + // `null` (unlike a missing preset) clears the brush. + if let Some(v) = p.get("preset") { + let v = if v.is_null() { Value::Null } else { p.clone() }; + s.tool.pencil_brush = super::props::brush_of(&v).ok_or_else(|| bad("tool.pencilBrush", super::props::BRUSH_HELP))?; + } + Ok(json!({"brush": s.tool.pencil_brush})) + } + ), spec!( "tool.scaleAnchor", "Scale anchor", diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index 85554fd..5583efe 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -16,7 +16,7 @@ fn focused(s: &Session) -> Option { fn click(s: &mut Session, x: f64, y: f64) { for kind in [PointerKind::Down, PointerKind::Up] { - let ev = PointerEvent { kind, x, y, sx: 0.0, sy: 0.0, shift: false, alt: false, cmd: false, ctrl: false, zoom: 1.0 }; + let ev = PointerEvent { kind, x, y, sx: 0.0, sy: 0.0, shift: false, alt: false, cmd: false, ctrl: false, zoom: 1.0, pressure: None }; s.pointer(&ev).unwrap(); } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index e4728dc..28d45af 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -56,6 +56,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { let points = crate::cmd::props::width_points_json(Some(p)); o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); } + if let Some(b) = &n.stroke.brush { + o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); + } } if !n.effects.is_empty() { o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7334959..842cde4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1188,6 +1188,8 @@ mod booleans_tests; #[cfg(test)] mod branch_tests; #[cfg(test)] +mod brush_tests; +#[cfg(test)] mod collab_ids_tests; #[cfg(test)] mod collab_live_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 8da52bc..6e96140 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -160,11 +160,13 @@ pub struct PointerEvent { pub ctrl: bool, /// Screen pixels per page unit. pub zoom: f64, + /// Pen pressure, 0..1, where the device reports it (`None`: a mouse, or a platform that doesn't). + pub pressure: Option, } impl PointerEvent { pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self { - Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom } + Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom, pressure: None } } pub(crate) fn p(&self) -> Point { Point::new(self.x, self.y) @@ -300,6 +302,8 @@ pub(crate) enum Gesture { }, Pencil { points: Vec, + /// The pen pressure at each point, where the device reports it. + pressure: Vec>, }, TextSelect { id: NodeId, @@ -361,6 +365,39 @@ pub struct ToolState { /// corners on the page, turned with its frame): the Move and Scale tools treat a press or /// hover on one as on its frame. pub labels: Vec<(NodeId, [Point; 4])>, + /// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke). + pub pencil_brush: Option, +} + +/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most +/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no +/// sample has pressure. +pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { + if pressure.iter().all(Option::is_none) { + return None; + } + let mut lengths = vec![0.0]; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); + } + let total = lengths.last().copied().unwrap_or(0.0); + if !(total.is_finite() && total > 0.0) { + return None; + } + let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); + let n = points.len().clamp(2, 32); + let profile = (0..n).map(|k| { + let t = k as f64 / (n - 1) as f64; + let s = t * total; + let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); + let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { + (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), + _ => at(i), + }; + [t, (p / 0.5).clamp(0.1, 2.0)] + }); + Some(profile.collect()) } /// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost @@ -770,7 +807,7 @@ impl Session { ToolId::Text => self.text_down(ev), ToolId::Pen => self.pen_down(ev), ToolId::Pencil => { - self.tool.gesture = Gesture::Pencil { points: vec![ev.p()] }; + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; Ok(()) } ToolId::Comment => { @@ -1123,13 +1160,14 @@ impl Session { ds.revision += 1; Ok(()) } - Gesture::Pencil { points } => { - let mut pts = points.clone(); + Gesture::Pencil { points, pressure } => { + let (mut pts, mut pressure) = (points.clone(), pressure.clone()); if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { pts.push(ev.p()); + pressure.push(ev.pressure); } self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); - self.tool.gesture = Gesture::Pencil { points: pts }; + self.tool.gesture = Gesture::Pencil { points: pts, pressure }; if let Ok(ds) = self.ds_mut() { ds.revision += 1; } @@ -1474,9 +1512,9 @@ impl Session { self.tool.tool = t; } } - Gesture::Pencil { points } => { + Gesture::Pencil { points, pressure } => { self.tool.overlay.pen.clear(); - self.pencil_finish(&points)?; + self.pencil_finish(&points, &pressure)?; } Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } => {} @@ -1731,7 +1769,7 @@ impl Session { Ok(()) } - fn pencil_finish(&mut self, points: &[Point]) -> Result<()> { + fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { if points.len() < 2 { return Ok(()); } @@ -1752,10 +1790,18 @@ impl Session { let mut local = fitted.clone(); local.apply_affine(pw.inverse()); let _ = d; + let mut props = + json!({"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); + // Pen pressure becomes the stroke's width profile (brushes follow it too). + if let Some(points) = pressure_profile(points, pressure) { + props["widthProfile"] = json!(points); + } + if let Some(b) = &self.tool.pencil_brush { + props["brush"] = serde_json::to_value(b).unwrap_or_default(); + } self.execute( "node.create", - &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", - "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}}), + &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), )?; Ok(()) } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 9938042..73575f6 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -26,6 +26,11 @@ fn b(p: &Value, k: &str) -> bool { figcraft_engine::params::boolean(p, k).unwrap_or(false) } +/// Pen pressure `k` (0..1), if given. +fn pressure(p: &Value, k: &str) -> Option { + f(p, k).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32) +} + fn f(p: &Value, k: &str) -> Option { figcraft_engine::params::num(p, k) } @@ -79,7 +84,10 @@ impl Headless { cmd: b(p, "cmd"), ctrl: b(p, "ctrl"), zoom, + pressure: pressure(p, "pressure"), }; + // A drag's pressure goes from `pressure` to `pressureTo`. + let (pr0, pr1) = (pressure(p, "pressure"), pressure(p, "pressureTo").or(pressure(p, "pressure"))); let kind = p.get("kind").and_then(Value::as_str).unwrap_or("click"); let mut events = Vec::new(); match kind { @@ -100,9 +108,10 @@ impl Headless { events.push(mk(PointerKind::Down, x, y)); for i in 1..=steps { let t = i as f64 / steps as f64; - events.push(mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t)); + let pressure = pr0.zip(pr1).map(|(a, b)| a + (b - a) * t as f32); + events.push(PointerEvent { pressure, ..mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t) }); } - events.push(mk(PointerKind::Up, tx, ty)); + events.push(PointerEvent { pressure: pr1, ..mk(PointerKind::Up, tx, ty) }); } other => return Err(format!("unknown pointer kind `{other}` (down|move|up|click|double|hover|drag)")), } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 12c25cd..4d80ae1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -265,6 +265,8 @@ pub fn tool_definitions() -> Vec { "shift": boolean("Shift held"), "alt": boolean("Alt/Option held"), "cmd": boolean("Cmd (macOS) / Ctrl held"), + "pressure": num("Pen pressure 0-1 (the Pencil records it as the stroke's width profile; a drag starts with it)"), + "pressureTo": num("Pen pressure 0-1 at a drag's end (interpolated along it)"), }), &["x", "y"], ), @@ -758,6 +760,11 @@ fn pointer(b: &mut dyn Backend, a: &Map) -> Result Option<&'static Preset> { + PRESETS.iter().find(|p| p.id == id) +} + +/// A preset's shape: stretch shapes span x 0..1 along the path and y -0.5..0.5 across it (one +/// stroke weight); scatter shapes are about one unit wide, centred on the origin, pointing along x. +fn shape(id: &str) -> Option { + let mut p = BezPath::new(); + match id { + "taper" => { + p.move_to((0.0, 0.0)); + p.curve_to((0.3, -0.5), (0.7, -0.5), (1.0, 0.0)); + p.curve_to((0.7, 0.5), (0.3, 0.5), (0.0, 0.0)); + p.close_path(); + } + // A blunt start, uneven edges and a long tail. + "ink" => band(&mut p, |x| { + let w = (x / 0.04).min(1.0) * ((1.0 - x) / 0.3).min(1.0); + (-0.5 * w + 0.04 * (9.0 * std::f64::consts::PI * x).sin() * w, 0.45 * w + 0.04 * (13.0 * std::f64::consts::PI * x + 1.0).sin() * w) + }), + // Edges that cross three times: a twisting ribbon. + "ribbon" => band(&mut p, |x| { + let c = 0.5 * (3.0 * std::f64::consts::PI * x).cos(); + (-c, c) + }), + // Four thin strands with ragged ends. + "bristle" => { + for k in 0..4 { + let (k, y) = (f64::from(k), -0.375 + 0.25 * f64::from(k)); + let (x0, x1) = (0.03 * k, 1.0 - 0.04 * (3.0 - k)); + let mut s = BezPath::new(); + band(&mut s, |x| { + let u = ((x - x0) / (x1 - x0)).clamp(0.0, 1.0); + let h = 0.09 * (std::f64::consts::PI * u).sin().max(0.0).sqrt(); + (y - h, y + h) + }); + p.extend(s); + } + } + "dots" => p.extend(kurbo::Shape::path_elements(&kurbo::Circle::new((0.0, 0.0), 0.5), 0.01)), + "stars" => { + let pts = figcraft_geom::star_points(1.0, 1.0, 5, 0.4); + for (i, q) in pts.iter().enumerate() { + let q = Point::new(q.x - 0.5, q.y - 0.5); + if i == 0 { p.move_to(q) } else { p.line_to(q) } + } + p.close_path(); + } + "confetti" => p.extend(kurbo::Shape::path_elements(&kurbo::Rect::new(-0.3, -0.3, 0.3, 0.3), 0.01)), + "leaves" => { + p.move_to((-0.5, 0.0)); + p.curve_to((-0.2, -0.3), (0.2, -0.3), (0.5, 0.0)); + p.curve_to((0.2, 0.3), (-0.2, 0.3), (-0.5, 0.0)); + p.close_path(); + } + _ => return None, + } + Some(p) +} + +/// A closed band from x 0 to 1 between edges `f(x) = (top, bottom)`. +fn band(p: &mut BezPath, f: impl Fn(f64) -> (f64, f64)) { + const N: usize = 64; + let xs = (0..=N).map(|i| i as f64 / N as f64); + for (i, x) in xs.clone().enumerate() { + let q = (x, f(x).0); + if i == 0 { p.move_to(q) } else { p.line_to(q) } + } + for x in xs.rev() { + p.line_to((x, f(x).1)); + } + p.close_path(); +} + +/// A repeatable pseudo-random number in 0..1 for copy `i`, channel `k` (splitmix64). +fn rand(seed: u32, i: u64, k: u64) -> f64 { + let mut z = u64::from(seed) ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ k.wrapping_mul(0xD1B5_4A32_D192_ED03); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + (z >> 11) as f64 / (1u64 << 53) as f64 +} + +/// The stroke of `path` drawn with `brush` at `width` (the profile, if any, scales it along each +/// subpath). `None` for an unknown preset or a path too long to measure. +pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) -> Option { + let p = preset(&brush.preset)?; + let unit = shape(p.id)?; + let lines = polylines(path)?; + let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t)); + let mut out = BezPath::new(); + match p.kind { + BrushKind::Stretch => { + let steps = (MAX_STRETCH_STEPS / lines.len().max(1)).clamp(8, STRETCH_STEPS); + for line in &lines { + stretch(&mut out, &unit, line, steps, &scale); + } + } + BrushKind::Scatter => scatter(&mut out, &unit, &lines, width, brush, &scale), + } + Some(out) +} + +/// `unit` bent along `line` in at most `steps` steps along it: x by length, y across at the scaled +/// width. +fn stretch(out: &mut BezPath, unit: &BezPath, line: &Polyline, steps: usize, scale: &dyn Fn(f64) -> f64) { + let map = |q: Point| line.at(q.x).map(|(p, n)| p + n * (q.y * scale(q.x))); + let mut last = Point::ZERO; + kurbo::flatten(unit.iter(), 0.002, |el| match el { + PathEl::MoveTo(q) => { + last = q; + if let Some(m) = map(q) { + out.move_to(m); + } + } + PathEl::LineTo(q) => { + // Short steps along x so the shape bends with the path. + let n = (((q.x - last.x).abs() * steps as f64).ceil() as usize).clamp(1, steps); + for k in 1..=n { + if let Some(m) = map(last.lerp(q, k as f64 / n as f64)) { + out.line_to(m); + } + } + last = q; + } + PathEl::ClosePath => out.close_path(), + _ => {} + }); +} + +/// Copies of `unit` along `lines`, `brush.spacing` weights apart, jittered. +fn scatter(out: &mut BezPath, unit: &BezPath, lines: &[Polyline], width: f64, brush: &Brush, scale: &dyn Fn(f64) -> f64) { + let total: f64 = lines.iter().map(Polyline::length).sum(); + let b = brush.sanitized(); + let step = (b.spacing * width).max(total / MAX_COPIES as f64).max(1e-3); + let mut i = 0u64; + for line in lines { + let len = line.length(); + let mut s = step / 2.0; + while s < len { + let t = s / len; + if let Some((p, n)) = line.at(t) { + let r = |k| rand(b.seed, i, k) * 2.0 - 1.0; + let size = scale(t) * (1.0 + b.size_jitter * r(0)).max(0.05); + let dir = Vec2::new(n.y, -n.x); + let angle = dir.atan2() + b.rotation_jitter * std::f64::consts::PI * r(1); + let at = p + n * (b.scatter * width * r(2)); + let xf = Affine::translate(at.to_vec2()) * Affine::rotate(angle) * Affine::scale(size); + out.extend(unit.iter().map(|el| xf * el)); + } + i += 1; + s += step; + } + } +} diff --git a/crates/pathops/src/brush_tests.rs b/crates/pathops/src/brush_tests.rs new file mode 100644 index 0000000..403935d --- /dev/null +++ b/crates/pathops/src/brush_tests.rs @@ -0,0 +1,74 @@ +//! Brushes: stretch shapes bend along the path, scatter copies are spaced, capped and repeatable, +//! and a width profile (pen pressure) scales them. + +use figcraft_doc::{Brush, StrokeAlign, StrokeProps, WidthProfile}; +use figcraft_geom::kurbo::{BezPath, Point, Shape}; + +use crate::brush::{MAX_COPIES, PRESETS, outline}; + +fn line(len: f64) -> BezPath { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((len, 0.0)); + p +} + +fn brush(preset: &str) -> Brush { + Brush { preset: preset.into(), ..Default::default() } +} + +fn props(weight: f64, b: &Brush) -> StrokeProps { + StrokeProps { weight, align: StrokeAlign::Center, brush: Some(b.clone()), ..Default::default() } +} + +#[test] +fn every_preset_draws_inside_its_reach() { + for p in PRESETS { + let b = brush(p.id); + let out = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap(); + let bb = out.bounding_box(); + assert!(!out.elements().is_empty(), "{}", p.id); + assert!(bb.y0 >= -5.5 && bb.y1 <= 5.5 && bb.x0 >= -5.5 && bb.x1 <= 205.5, "{}: {bb:?}", p.id); + } + assert!(outline(&line(10.0), 1.0, &props(1.0, &brush("nope")), &brush("nope")).is_none()); +} + +#[test] +fn stretch_bends_with_the_path() { + // Down a vertical line, the taper's widest point sits halfway down, across x. + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((0.0, 100.0)); + let b = brush("taper"); + let out = outline(&p, 20.0, &props(20.0, &b), &b).unwrap(); + let bb = out.bounding_box(); + assert!((bb.height() - 100.0).abs() < 1.0 && (bb.width() - 15.0).abs() < 1.5, "{bb:?}"); + assert_ne!(out.winding(Point::new(0.0, 50.0)), 0, "filled in the middle"); + assert_eq!(out.winding(Point::new(6.0, 2.0)), 0, "pointed at the start"); +} + +#[test] +fn scatter_is_spaced_capped_and_repeatable() { + let b = Brush { spacing: 2.0, size_jitter: 0.5, rotation_jitter: 1.0, scatter: 1.0, seed: 7, ..brush("dots") }; + let a = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap(); + assert_eq!(a, outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap(), "the same seed, the same copies"); + let c = Brush { seed: 8, ..b.clone() }; + assert_ne!(a, outline(&line(200.0), 10.0, &props(10.0, &c), &c).unwrap()); + // 200 long, a copy every 20: ten circles (each a move-to). + let moves = |p: &BezPath| p.elements().iter().filter(|e| matches!(e, figcraft_geom::kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(moves(&a), 10); + // A tiny spacing on a long path stops at the cap. + let dense = Brush { spacing: 0.0, ..brush("dots") }; + assert!(moves(&outline(&line(1e6), 1.0, &props(1.0, &dense), &dense).unwrap()) <= MAX_COPIES + 1); +} + +#[test] +fn a_width_profile_scales_the_brush() { + let b = brush("dots"); + let mut pr = props(10.0, &b); + pr.width_profile = Some(WidthProfile::new([(0.0, 0.2), (1.0, 2.0)])); + let out = outline(&line(300.0), 10.0, &pr, &b).unwrap(); + let parts = figcraft_geom::split_subpaths(&out); + let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box()); + assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}"); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 41b6973..d712fb5 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -3,9 +3,12 @@ //! - [`boolean`]: Union, Subtract, Intersect and Exclude over any number of closed paths (robust //! sweep-line topology from `linesweeper`); results use the even-odd rule. //! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment, -//! dashes, caps, joins, arrowheads and variable width profiles ([`width`]). +//! dashes, caps, joins, arrowheads, variable width profiles ([`width`]) and brushes ([`brush`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod brush; +#[cfg(test)] +mod brush_tests; #[cfg(test)] mod ops_tests; #[cfg(test)] @@ -279,9 +282,10 @@ pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) - return BezPath::new(); } // Centred at `width` (twice the weight for Inside/Outside, which clip half of it away). - let centred = |width: f64| match props.profile() { - Some(p) => width::variable_outline(path, width, props, p).unwrap_or_else(|| stroke_center(path, width, props)), - None => stroke_center(path, width, props), + let centred = |width: f64| match (&props.brush, props.profile()) { + (Some(b), _) => brush::outline(path, width, props, b).unwrap_or_else(|| stroke_center(path, width, props)), + (None, Some(p)) => width::variable_outline(path, width, props, p).unwrap_or_else(|| stroke_center(path, width, props)), + (None, None) => stroke_center(path, width, props), }; let align = if closed_shape { props.align } else { StrokeAlign::Center }; let mut out = match align { diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index c0d8a50..3a196a0 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -92,7 +92,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)); } // Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines. - if props.dashes.is_empty() && props.profile().is_none() && closed && props.align != StrokeAlign::Center { + if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center { let w = props.weight; let ring = |inset: f64| -> Option { let (x0, y0, ww, hh) = (inset, inset, n.width - 2.0 * inset, n.height - 2.0 * inset); @@ -141,6 +141,9 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { for p in props.profile().map(|p| p.points.as_slice()).unwrap_or_default() { (p.t.to_bits(), p.scale.to_bits(), p.bias.to_bits()).hash(&mut h); } + if let Some(b) = &props.brush { + (&b.preset, b.spacing.to_bits(), b.size_jitter.to_bits(), b.rotation_jitter.to_bits(), b.scatter.to_bits(), b.seed).hash(&mut h); + } let key = h.finish(); if let Some(p) = locked(stroke_cache()).get(key) { return p; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f38707d..ea2caea 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -15,6 +15,7 @@ figcraft-doc = { workspace = true } figcraft-text = { workspace = true } figcraft-layout = { workspace = true } figcraft-render = { workspace = true } +figcraft-pathops = { workspace = true } figcraft-engine = { workspace = true } egui = { workspace = true } serde = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 142d4d9..a05bea1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -28,6 +28,9 @@ pub struct CanvasState { zoom_drag: Option, pub last_pointer: Option, preedit: Option, + /// The pen pressure last reported during the current press (pen and stylus touches; the + /// winit backends and browsers that report it). + pressure: Option, } impl Default for CanvasState { @@ -45,6 +48,7 @@ impl Default for CanvasState { zoom_drag: None, last_pointer: None, preedit: None, + pressure: None, } } } @@ -118,7 +122,19 @@ impl FigApp { fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) -> PointerEvent { let p = app.to_page(pos); let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); - PointerEvent { kind, x: p.x, y: p.y, sx: f64::from(pos.x), sy: f64::from(pos.y), shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, zoom } + PointerEvent { + kind, + x: p.x, + y: p.y, + sx: f64::from(pos.x), + sy: f64::from(pos.y), + shift: m.shift, + alt: m.alt, + cmd: m.command, + ctrl: m.ctrl, + zoom, + pressure: app.canvas.pressure, + } } pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { @@ -444,8 +460,22 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { } else { None }; + // Pen pressure comes with touch events (a pen on Windows, a stylus in a browser); a mouse, + // macOS and Linux tablets report none and draw at the stroke weight. + let force = ctx.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => Some(f.clamp(0.0, 1.0)), + _ => None, + }) + }); + if force.is_some() { + app.canvas.pressure = force; + } if let Some(k) = kind { let ev = pointer_event(app, k, pos, &mods); + if k == PointerKind::Up { + app.canvas.pressure = None; + } app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); if let Err(e) = app.session.pointer(&ev) { app.toast(e.to_string()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e3263f6..7e34f65 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -536,6 +536,7 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result { }; let f = |k: &str| p.get(k).and_then(Value::as_f64); let (x, y) = at(f("x"), f("y"), f("sx"), f("sy")).ok_or("pass x, y (page) or sx, sy (screen)")?; + let pressure = |k: &str| f(k).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32); let mk = |kind: PointerKind, x: f64, y: f64| PointerEvent { kind, x, @@ -547,7 +548,10 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result { cmd: b(p, "cmd"), ctrl: b(p, "ctrl"), zoom, + pressure: pressure("pressure"), }; + // A drag's pressure goes from `pressure` to `pressureTo`. + let (pr0, pr1) = (pressure("pressure"), pressure("pressureTo").or(pressure("pressure"))); let kind = p.get("kind").and_then(Value::as_str).unwrap_or("click"); let run = |app: &mut FigApp, ev: PointerEvent| app.session.pointer(&ev).map_err(|e| e.to_string()); match kind { @@ -568,9 +572,10 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result { run(app, mk(PointerKind::Down, x, y))?; for i in 1..=steps { let t = i as f64 / steps as f64; - run(app, mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t))?; + let pressure = pr0.zip(pr1).map(|(a, b)| a + (b - a) * t as f32); + run(app, PointerEvent { pressure, ..mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t) })?; } - run(app, mk(PointerKind::Up, tx, ty))?; + run(app, PointerEvent { pressure: pr1, ..mk(PointerKind::Up, tx, ty) })?; } other => return Err(format!("unknown pointer kind `{other}`")), } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 2716dd3..89161d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1173,6 +1173,112 @@ fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Opt }); } +/// A brush drawn along a short line across `rect`, as outlines (egui fills only convex shapes). +pub(crate) fn brush_art(painter: &egui::Painter, rect: Rect, preset: &str, col: Color32) { + use figcraft_geom::kurbo::{self, PathEl}; + let mut line = figcraft_geom::BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((f64::from(rect.width()), 0.0)); + let brush = Brush::new(preset); + let props = StrokeProps { weight: f64::from(rect.height()), align: StrokeAlign::Center, brush: Some(brush.clone()), ..Default::default() }; + let Some(out) = figcraft_pathops::brush::outline(&line, props.weight, &props, &brush) else { return }; + for sub in figcraft_geom::split_subpaths(&out) { + let mut pts = Vec::new(); + kurbo::flatten(sub.iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(pos2(rect.min.x + p.x as f32, rect.center().y + p.y as f32)); + } + }); + painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, col))); + } +} + +/// Stroke settings ▸ Brush: a plain stroke, a stretch brush or a scatter brush, each with its look. +fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option>, width: f32) { + use figcraft_pathops::brush::{PRESETS, preset}; + let t = Tokens::get(ui.ctx()); + widgets::sublabels(ui, "Brush", None); + row(ui, 32.0, |ui| { + let label = match &brush { + None => "Mixed", + Some(b) => b.as_ref().and_then(|b| preset(&b.preset)).map_or("None", |p| p.label), + }; + let resp = widgets::dropdown(ui, label, width, None); + a11y::combo(&resp, "Brush", label, a11y::menu_open(&resp)); + if let Some(Some(b)) = &brush { + let r = resp.rect; + brush_art( + ui.painter(), + Rect::from_min_max(pos2(r.max.x - 22.0 - 56.0, r.center().y - 5.0), pos2(r.max.x - 22.0, r.center().y + 5.0)), + &b.preset, + t.icon_secondary, + ); + } + menu_popup(&resp).show(|ui| { + if menu_item(ui, "None", "") { + app.run("stroke.brush", json!({"ids": ids, "preset": null})); + ui.close(); + } + for p in PRESETS { + let art = |pt: &egui::Painter, r: Rect, col: Color32| { + brush_art( + pt, + Rect::from_min_max(pos2(r.max.x - 8.0 - 64.0, r.center().y - 5.0), pos2(r.max.x - 8.0, r.center().y + 5.0)), + p.id, + col, + ); + }; + if menu_item_with(ui, p.label, "", 8.0 + 64.0, art) { + app.run("stroke.brush", json!({"ids": ids, "preset": p.id})); + ui.close(); + } + } + }); + }); + // A scatter brush's spacing and jitter. + let Some(Some(b)) = + brush.filter(|b| b.as_ref().and_then(|b| preset(&b.preset)).is_some_and(|p| p.kind == figcraft_pathops::brush::BrushKind::Scatter)) + else { + return; + }; + let half = (width - 8.0) / 2.0; + let with = |f: &dyn Fn(&mut Brush)| { + let mut nb = b.clone(); + f(&mut nb); + serde_json::to_value(nb.sanitized()).unwrap_or_default() + }; + widgets::sublabels(ui, "Spacing", Some("Scatter")); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("brush_spacing"), Lead::Icon(Icon::Gap, "Brush spacing"), Some(b.spacing), "×", half, 0.1); + app.field(ids, "brush", e, |v| with(&|nb| nb.spacing = v)); + let e = widgets::number_field(ui, Id::new("brush_scatter"), Lead::Icon(Icon::Dots, "Brush scatter"), Some(b.scatter), "×", half, 0.1); + app.field(ids, "brush", e, |v| with(&|nb| nb.scatter = v)); + }); + widgets::sublabels(ui, "Size jitter", Some("Rotation jitter")); + row(ui, 32.0, |ui| { + let e = widgets::number_field( + ui, + Id::new("brush_size_jitter"), + Lead::Icon(Icon::Scale, "Brush size jitter"), + Some(b.size_jitter * 100.0), + "%", + half, + 1.0, + ); + app.field(ids, "brush", e, |v| with(&|nb| nb.size_jitter = v / 100.0)); + let e = widgets::number_field( + ui, + Id::new("brush_rotation_jitter"), + Lead::Icon(Icon::Rotate, "Brush rotation jitter"), + Some(b.rotation_jitter * 100.0), + "%", + half, + 1.0, + ); + app.field(ids, "brush", e, |v| with(&|nb| nb.rotation_jitter = v / 100.0)); + }); +} + /// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths. fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); @@ -1206,6 +1312,7 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); } width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()), half); + brush_row(app, ui, &ids, st.as_ref().map(|s| s.brush.clone()), half * 2.0 + 8.0); let join = st.as_ref().map(|s| s.join); widgets::sublabels(ui, "Join", (join == Some(StrokeJoin::Miter)).then_some("Miter angle")); row(ui, 32.0, |ui| { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 884e421..7772a46 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -98,11 +98,12 @@ session itself is `engine.execute {command: "collab.peers"}` (hosting, joining a see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it was started with (`figcraft "figcraft://join?…"`). -### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}` +### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?, pressure?, pressureTo?}` A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `double`, `hover`, `drag` (needs `to: [x, y]`; `steps` intermediate moves, default 8). Position in page coordinates -(`x`, `y`) or screen pixels (`sx`, `sy`). Returns `{selection, tool}`. With the Move and Scale +(`x`, `y`) or screen pixels (`sx`, `sy`). `pressure` (0–1) is pen pressure (the Pencil records +it); a drag's pressure goes from `pressure` to `pressureTo`. Returns `{selection, tool}`. With the Move and Scale tools, a press on a frame's or section's name label (as the canvas last drew it, along a rotated frame's top edge) selects and drags that frame. diff --git a/docs/gaps.md b/docs/gaps.md index 54e0d32..079d123 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -22,7 +22,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 6 | Plugins: no run-time UI, distribution or widgets; Figma's plugins don't run | Ecosystem | 25–55 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | -| 9 | Figma Draw: stretch and scatter brushes, pen pressure (text on a path and pattern fills landed) | Feature | 5–10 | [target-app-parity](target-app-parity.md) | +| 9 | Figma Draw: multi-edit, pen pressure on macOS/Linux tablets (brushes, pressure, text on a path and pattern fills landed) | Feature | 2–5 | [target-app-parity](target-app-parity.md) | | 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | | 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | @@ -137,12 +137,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe desktop app). - **Estimate:** 10–20 h, partly human (hardware). -### 9. Figma Draw: brushes and pressure -- **Missing:** stretch and scatter brushes, pen pressure for the pencil and brushes, multi-edit. +### 9. Figma Draw: multi-edit and tablet pressure +- **Missing:** multi-edit; pen pressure on macOS and Linux tablets (winit reports none there). - **Landed:** text on a path (`text.onPath`, `text.pathOptions`, `text.removeFromPath`; the Design panel's Typography section; canvas, PNG, SVG and PDF as outlines; SVG `` imports - as text on a path) and pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's - Pattern tab; SVG ``, PDF tiling patterns). + as text on a path), pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's + Pattern tab; SVG ``, PDF tiling patterns), stretch and scatter brushes (`stroke.brush`, + eight presets drawn in code; canvas, PNG, SVG, PDF and outline stroke) and pen pressure for the + Pencil (Windows pens and browser styluses; recorded as the stroke's width profile, which brushes + follow). - **Evidence:** no pressure input anywhere; strokes have uniform and variable widths only. - **Impact:** illustration-flavoured work moved into Figma Draw in 2025. - **Estimate:** 5–10 h. @@ -260,6 +263,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 67985f7..35d9281 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -17,7 +17,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | HiDPI / Retina | yes | yes (all) | — | | Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB (egui) | 3–6 | | Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — | -| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | no | 3–6 | +| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 | | Several windows / multi-monitor | yes (a window per file; presenter in its own window) | one window with file tabs; presenter inside it | 4–8 | | Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA | | Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 | @@ -50,4 +50,5 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | | 2026-10-10 | major | Created from the 2026-10-10 re-measure | diff --git a/docs/mcp.md b/docs/mcp.md index cd4d959..bc445e5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -137,7 +137,7 @@ Or in a project's `.mcp.json`: | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window when attached. | | `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. | | `undo` / `redo` | Step through history. | -| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. | +| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | | `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | | `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. | | `inspect_ui {}` | Tool, view and status (and panels/timings when attached). | @@ -806,6 +806,25 @@ noise and texture over the result. ships with FigCraft. Soft masks stay RGB (they aren't printed) and transparency groups blend in RGB, as the PDF writer declares them. +### Brushes and pen pressure + +- `stroke.brush {ids?, preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?}` draws a + layer's stroke with a brush (`preset: null` back to a plain stroke; a layer without a stroke gets + a black one); `node.set {brush}` takes the same (a preset id, an object or `null`) and `get_node` + shows `brush`. Stretch brushes bend one shape along each subpath: `taper`, `ink`, `ribbon`, + `bristle`. Scatter brushes drop copies along it every `spacing` stroke weights (default 1.5; at + most 5,000 copies a path): `dots`, `stars`, `confetti`, `leaves`, varied by `sizeJitter` (0–1), + `rotationJitter` (0–1, up to half a turn) and `scatter` (0–4 weights off the path), repeatably + from `seed`. The stroke weight sets the brush's size and a width profile scales it along the + path. Brushes draw on the canvas, in PNG, SVG and PDF (as filled outlines) and `vector.outlineStroke` + flattens them; dashes don't apply to brushes. +- **Pen pressure**: the Pencil records pressure into the stroke's width profile (up to 32 points by + length; medium pressure, 0.5, draws the stroke weight, from a tenth of it to twice it); without + pressure (a mouse) the stroke stays uniform. `pointer` takes `pressure` and, for drags, + `pressureTo`. `tool.pencilBrush {preset?, …}` sets the brush new Pencil strokes get (`null` + clears it; no `preset` reads it). The desktop app reads pressure from pen input on Windows; in the + browser from stylus touch events that report a force; macOS and Linux tablets report none. + ### Paint, effect and layout grid lists A layer's fills, strokes, effects and layout grids are lists kept **bottom first**: index 0 paints diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e0dace..c0070cf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -50,7 +50,7 @@ blockers. | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | keyboard box selection, interaction pass (Open recent and the outline mode variants landed 2026-10-11) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | -| M4 Vector & text depth | ~78% (live booleans, arc fields, shape builder on strokes, one-sided widths, text on a path and pattern fills landed 2026-10-11) | spell check, Figma Draw (brushes, pressure), boolean hit testing on the result | 13–24 | +| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit, boolean hit testing on the result | 8–19 | | M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 | | M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent, Code Connect | 40–80 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | @@ -65,13 +65,14 @@ blockers. - Team libraries for a whole team (lists, accounts and permissions) and deeper library analytics (4–8 h, after accounts), branching (8–15 h). - GPU canvas held to the CPU renderer (30–60 h). -- Figma Draw brushes and pen pressure (5–10 h). +- Figma Draw multi-edit and pen pressure on macOS/Linux tablets (2–5 h). - Mobile viewer and prototype mirror (20–40 h). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 28ee036..9f2a520 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) | | Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × discounts 0.9 × 0.9 × 0.8) | | Remaining to beta | **~200–365 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~378–722 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~373–712 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -99,7 +99,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 | | 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 | | 5 | Paint | 7 | 87% | 66% | Video fills, wide-gamut canvas, embedded image profiles, HEIC images (animated GIF fills and TIFF images landed) | 4–7 | -| 6 | Strokes | 3 | 90% | 72% | Stroke styles, brush strokes (see 20) | 2–4 | +| 6 | Strokes | 3 | 90% | 72% | Stroke styles (brush strokes landed, see 20) | 1–3 | | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 | | 9 | Auto layout & constraints | 9 | 95% | 68% | Grid `fr` rows around fixed children; behaviour against real Figma files ([auto-layout-parity](auto-layout-parity.md)) | 6–12 | @@ -113,10 +113,10 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 | | 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | -| 20 | Figma Draw extras | 2 | 65% | 45% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills. Missing: brushes (stretch/scatter), pressure, multi-edit | 5–10 | +| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **206–411** | -| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 86–168 | +| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **200–401** | +| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 85–167 | ## By dimension (ready for real work) @@ -128,12 +128,12 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | -| Hardware | 3 | 50% | 15–30 | Trackpad pinch and HiDPI yes; no pen pressure, no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) | +| Hardware | 3 | 55% | 13–27 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) | | Localization | 3 | 10% | 60–100 | English only; Figma ships 7 more UI languages | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 20% | 25–55 | Own WASM plugins with a Rust SDK and two examples (#101); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~47%** | **~378–722** | | | +| **Ready for real work** | 100 | **~47%** | **~373–712** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fec0c90..1f37951 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -53,7 +53,7 @@ interaction feel not yet compared. | Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | | | Frame (presets panel, swap orientation), section, slice | done | | | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | | -| Pen: vector networks, branch, close, bend; pencil | done | no pen pressure | +| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | hit-tested by the operands, not the result | @@ -61,7 +61,7 @@ interaction feel not yet compared. | Eyedropper (I) | done | | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | -| Brushes (Figma Draw) | missing | | +| Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | ## Canvas