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).
This commit is contained in:
BFlat authored and GitHub committed 2026-10-11 13:43:48 +07:00
1 parent f62cb4b1f6
commit cb5435d5ff
29 files changed
+802 -56

No files matched your search

Generated
+1
View File
@@ -1665,6 +1665,7 @@ dependencies = [
"figcraft-engine",
"figcraft-geom",
"figcraft-layout",
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"image",
+6 -5
View File
@@ -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 `<textPath>` 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 `<pattern>`, 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 |
+13 -2
View File
@@ -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,
);
+57 -2
View File
@@ -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<WidthProfile>,
/// A brush the stroke is drawn with instead of a plain band (`None`: plain).
#[serde(skip_serializing_if = "Option::is_none")]
pub brush: Option<Brush>,
}
/// 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<String>) -> 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)
}
}
+109
View File
@@ -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("<path"));
assert!(figcraft_pdf::export_nodes_pdf(doc, &[v]).is_ok());
}
#[test]
fn pressure_becomes_a_width_profile() {
let pts: Vec<Point> = (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<Option<f32>> = (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());
}
+23
View File
@@ -223,6 +223,13 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
ok()
}
fn stroke_brush(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
let ids = req_targets("node.reparent", s, p)?;
let parent = req_id("node.reparent", p, "parent")?;
+17 -2
View File
@@ -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<Option<Brush>> {
let b = match v {
Value::Null => return Some(None),
Value::String(id) => Brush::new(id.as_str()),
Value::Object(_) => serde_json::from_value::<Brush>(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<String, Valu
doc.get_mut(id)?.stroke.dashes = d;
}
"widthProfile" => 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",
+14
View File
@@ -258,6 +258,20 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+1 -1
View File
@@ -16,7 +16,7 @@ fn focused(s: &Session) -> Option<NodeId> {
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();
}
}
+3
View File
@@ -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)));
+2
View File
@@ -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;
+56 -10
View File
@@ -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<f32>,
}
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<Point>,
/// The pen pressure at each point, where the device reports it.
pressure: Vec<Option<f32>>,
},
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<figcraft_doc::Brush>,
}
/// 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<f32>]) -> Option<Vec<[f64; 2]>> {
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<f32>]) -> 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(())
}
+11 -2
View File
@@ -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<f32> {
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<f64> {
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)")),
}
+7
View File
@@ -265,6 +265,8 @@ pub fn tool_definitions() -> Vec<Value> {
"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<String, Value>) -> Result<ToolResult, St
if let Some(s) = n_arg(a, "steps")? {
p.insert("steps".into(), json!(s.clamp(1.0, 200.0) as u64));
}
for k in ["pressure", "pressureTo"] {
if let Some(v) = n_arg(a, k)? {
p.insert(k.into(), json!(v.clamp(0.0, 1.0)));
}
}
mods(a, &mut p)?;
b.call("ui.pointer", Value::Object(p)).map(|v| ToolResult::json(&v))
}
+199
View File
@@ -0,0 +1,199 @@
//! Brushes: strokes drawn with a shape instead of a band. A stretch brush bends one shape along
//! each subpath; a scatter brush drops copies of a shape along it, spaced by the stroke weight and
//! jittered in size, rotation and distance from the path by a seeded (repeatable) sequence. The
//! shapes are the built-in presets below, drawn in code. A width profile (pen pressure) scales the
//! brush along the path. The result may overlap itself; fill it with the non-zero rule.
use crate::width::{Polyline, polylines};
use figcraft_doc::{Brush, StrokeProps};
use figcraft_geom::kurbo::{self, Affine, BezPath, PathEl, Point, Vec2};
/// What a preset does with its shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BrushKind {
Stretch,
Scatter,
}
/// A built-in brush: id (saved in files), label, kind.
pub struct Preset {
pub id: &'static str,
pub label: &'static str,
pub kind: BrushKind,
}
/// The built-in brushes, stretch first.
pub const PRESETS: &[Preset] = &[
Preset { id: "taper", label: "Taper", kind: BrushKind::Stretch },
Preset { id: "ink", label: "Ink", kind: BrushKind::Stretch },
Preset { id: "ribbon", label: "Ribbon", kind: BrushKind::Stretch },
Preset { id: "bristle", label: "Bristle", kind: BrushKind::Stretch },
Preset { id: "dots", label: "Dots", kind: BrushKind::Scatter },
Preset { id: "stars", label: "Stars", kind: BrushKind::Scatter },
Preset { id: "confetti", label: "Confetti", kind: BrushKind::Scatter },
Preset { id: "leaves", label: "Leaves", kind: BrushKind::Scatter },
];
/// The most copies a scatter brush makes along a whole path; denser spacing spreads them out.
pub const MAX_COPIES: usize = 5_000;
/// Along-path steps of a stretched shape, per subpath.
const STRETCH_STEPS: usize = 512;
/// Along-path steps of a stretched shape over the whole path (many subpaths get fewer each).
const MAX_STRETCH_STEPS: usize = 65_536;
/// The built-in brush `id`.
pub fn preset(id: &str) -> 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<BezPath> {
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<BezPath> {
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;
}
}
}
+74
View File
@@ -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:?}");
}
+8 -4
View File
@@ -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 {
+4 -1
View File
@@ -92,7 +92,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
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<BezPath> {
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<BezPath> {
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;
+1
View File
@@ -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 }
+31 -1
View File
@@ -28,6 +28,9 @@ pub struct CanvasState {
zoom_drag: Option<Pos2>,
pub last_pointer: Option<Point>,
preedit: Option<PreeditView>,
/// The pen pressure last reported during the current press (pen and stylus touches; the
/// winit backends and browsers that report it).
pressure: Option<f32>,
}
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());
+7 -2
View File
@@ -536,6 +536,7 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result<Value, String> {
};
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<Value, String> {
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<Value, String> {
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}`")),
}
+107
View File
@@ -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<Option<Brush>>, 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| {
+3 -2
View File
@@ -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.
+10 -6
View File
@@ -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 `<textPath>` imports
as text on a path) and pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's
Pattern tab; SVG `<pattern>`, PDF tiling patterns).
as text on a path), pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's
Pattern tab; SVG `<pattern>`, 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 `<textPath>` 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 `<pattern>`, 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 |
+3 -2
View File
@@ -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 |
+20 -1
View File
@@ -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
+4 -3
View File
@@ -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 `<textPath>` 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 `<pattern>`, 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 |
+9 -8
View File
@@ -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 `<textPath>` 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 `<pattern>`, 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 |
+2 -2
View File
@@ -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