mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-13 23:17:43 +00:00
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:
1 parent
f62cb4b1f6
commit
cb5435d5ff
29 files changed
+802
-56
No files matched your search
Generated
+1
@@ -1665,6 +1665,7 @@ dependencies = [
|
||||
"figcraft-engine",
|
||||
"figcraft-geom",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"image",
|
||||
|
||||
+6
-5
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)")),
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}`")),
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user