M8: canvas fixes: boolean hit testing, group drags, frame outlines and slices view, a flaky idle test (#123)

Clicks on a boolean group test its computed result, so a click in a subtracted hole reaches what is behind it. Layers inside a group, boolean group or instance stay in their parent when dragged (one shared rule). View > Frame outlines and Slices now draw. The idle test's flake came from the render worker clearing its in-flight flag before waking the UI; jobs now carry sequence numbers.
This commit is contained in:
BFlat
2026-10-11 14:25:25 +07:00
committed by GitHub
parent 13ab3f150f
commit 09a3b74466
28 changed files with 405 additions and 119 deletions
+5 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; 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
@@ -127,6 +127,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a
subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame
outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13).
- **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps
the file's library on the server under the file (per-asset versions; the server's version is followed); other files
enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check
@@ -171,6 +174,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 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 |
+4 -1
View File
@@ -157,7 +157,10 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
let (fx, fy) = ((i * 7919 % 1000) as f64 / 1000.0, (i * 104_729 % 1000) as f64 / 1000.0);
Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy)
};
table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0)) * 1000.0);
{
let result = figcraft_engine::booleans::result_outline(&doc, &[]);
table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0, Some(&result))) * 1000.0);
}
measure_boolean_drag(table, col);
let mut s = Session::with_document(doc);
+3 -3
View File
@@ -27,8 +27,8 @@ fn setup() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) {
fn hits_reach_operands_but_clicks_pick_the_group() {
let (d, f, g, b, r1, r2) = setup();
let p = page(&d);
assert_eq!(d.hit_test(p, Point::new(20.0, 20.0), 0.0), Some(r1));
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0), Some(r2), "the top operand where they overlap");
assert_eq!(d.hit_test(p, Point::new(20.0, 20.0), 0.0, None), Some(r1));
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(r2), "the top operand where they overlap");
// A plain click: the group in the frame; Cmd-click: the operand.
assert_eq!(select_target(&d, r1, &[], false), g);
assert_eq!(select_target(&d, r1, &[], true), r1);
@@ -36,7 +36,7 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
// click on another operand picks that one.
assert_eq!(select_target(&d, r2, &[r1], false), r2);
// "Select layer" still lists the boolean group as one layer.
let listed: Vec<NodeId> = crate::geometry::layers_at(&d, p, Point::new(20.0, 20.0), 0.0).into_iter().map(|(id, _)| id).collect();
let listed: Vec<NodeId> = crate::geometry::layers_at(&d, p, Point::new(20.0, 20.0), 0.0, None).into_iter().map(|(id, _)| id).collect();
assert_eq!(listed, vec![f, g, b]);
}
+49 -30
View File
@@ -66,31 +66,46 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
_ => {
let Some(path) = shape_path(n) else { return false };
if is_closed(n) && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) && path.winding(lp) != 0 {
return true;
}
// Near the outline.
let reach = (n.stroke.max_weight() / 2.0).max(tol_l);
if !path.bounding_box().inflate(reach, reach).contains(lp) {
return false;
}
near_path(&path, lp, reach)
}
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, is_closed(n) && path.winding(lp) != 0)),
}
}
/// Whether local point `lp` hits `n` drawn as `path`: `inside` it (counted when it's filled, or
/// draws nothing at all), or within reach of its outline (its stroke, else the tolerance).
fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> bool {
if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) {
return true;
}
let reach = (n.stroke.max_weight() / 2.0).max(tol);
if !path.bounding_box().inflate(reach, reach).contains(lp) {
return false;
}
near_path(path, lp, reach)
}
/// A boolean group's computed result outline in its local space (filled even-odd), or `None` to
/// hit-test its operands instead. Boolean operations live above this crate: callers that can
/// compute results pass them in; `None` for the whole argument hit-tests operands everywhere.
pub type BoolResult<'a> = Option<&'a dyn Fn(NodeId, &Node) -> Option<BezPath>>;
/// With a result for boolean group `id`, whether `pt` hits it (`None`: no result, test operands).
fn hits_result(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, result: BoolResult) -> Option<bool> {
let path = result.and_then(|f| f(id, n))?;
let lp = doc.world_transform(id).inverse() * pt;
Some(hits_path(n, &path, lp, tol, path.winding(lp) % 2 != 0))
}
fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
use figcraft_geom::kurbo::ParamCurveNearest;
path.segments().any(|s| s.nearest(p, 0.01).distance_sq <= dist * dist)
}
/// The innermost visible, unlocked node under `pt` (page coordinates), honouring clip content.
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Option<NodeId> {
/// The innermost visible, unlocked node under `pt` (page coordinates), honouring clip content;
/// boolean groups are hit where their `result` is.
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
let p = doc.node(page)?;
for c in p.children.iter().rev() {
if let Some(h) = hit_rec(doc, *c, pt, tol, 0) {
if let Some(h) = hit_rec(doc, *c, pt, tol, 0, result) {
return Some(h);
}
}
@@ -98,11 +113,11 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Option<Nod
}
/// Like [`hit_leaf`], but only within layer `root` (it included).
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64) -> Option<NodeId> {
hit_rec(doc, root, pt, tol, 0)
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
hit_rec(doc, root, pt, tol, 0, result)
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Option<NodeId> {
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult) -> Option<NodeId> {
if depth > 512 {
return None;
}
@@ -117,15 +132,19 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Opt
if clips && !hits_node_itself(doc, id, n, pt, 0.0) {
return None;
}
// A boolean group is hit where an operand is (an approximation of its result). The leaf is the
// operand, so a plain click still selects the group (see `select_target`) while a deep click,
// a double click or a selected sibling operand reaches the operand itself.
// A boolean group is hit where its result is (without one: where an operand is). The leaf is
// the operand under the point, so a plain click still selects the group (see `select_target`)
// while a deep click, a double click or a selected sibling operand reaches the operand itself.
let in_result = if matches!(n.kind, NodeKind::BooleanOp { .. }) { hits_result(doc, id, n, pt, tol, result) } else { None };
if in_result == Some(false) {
return None;
}
for c in n.children.iter().rev() {
if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1) {
if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1, result) {
return Some(h);
}
}
if hits_node_itself(doc, id, n, pt, tol) {
if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol) {
return Some(id);
}
None
@@ -133,10 +152,10 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Opt
/// Every visible, unlocked layer under `pt` (page coordinates) with its depth below the page, in
/// layer-tree order (each layer before what it contains, the front-most first): what
/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups and
/// sections with hidden contents count as one layer.
pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Vec<(NodeId, usize)> {
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, out: &mut Vec<(NodeId, usize)>) {
/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups (hit where
/// their `result` is) and sections with hidden contents count as one layer.
pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Vec<(NodeId, usize)> {
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, out: &mut Vec<(NodeId, usize)>) {
let Some(n) = doc.node(id) else { return };
if !n.visible || n.locked || depth > 512 {
return;
@@ -148,13 +167,13 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Vec<(Node
let at = out.len();
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
if whole {
if hit_rec(doc, id, pt, tol, depth).is_some() {
if hit_rec(doc, id, pt, tol, depth, result).is_some() {
out.push((id, depth));
}
return;
}
for c in n.children.iter().rev() {
rec(doc, *c, pt, tol, depth + 1, out);
rec(doc, *c, pt, tol, depth + 1, result, out);
}
if out.len() > at || hits_node_itself(doc, id, n, pt, tol) {
out.insert(at, (id, depth));
@@ -163,7 +182,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Vec<(Node
let mut out = Vec::new();
if let Some(p) = doc.node(page) {
for c in p.children.iter().rev() {
rec(doc, *c, pt, tol, 0, &mut out);
rec(doc, *c, pt, tol, 0, result, &mut out);
}
}
out
+4 -3
View File
@@ -980,9 +980,10 @@ impl Document {
/// Hit test: the topmost visible, unlocked node under `pt` on `page` for a click.
/// `deep` selects the innermost leaf (Cmd-click); otherwise top-level children of frames
/// resolve like Figma's click-through rules (see [`geometry::select_target`]).
pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64) -> Option<NodeId> {
geometry::hit_leaf(self, page, pt, tolerance)
/// resolve like Figma's click-through rules (see [`geometry::select_target`]). Boolean groups
/// are hit where their `result` is (see [`geometry::BoolResult`]).
pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64, result: geometry::BoolResult) -> Option<NodeId> {
geometry::hit_leaf(self, page, pt, tolerance, result)
}
}
+6 -6
View File
@@ -116,12 +116,12 @@ fn hit_test_respects_z_and_clip() {
let a = d.create(f, NodeKind::Rectangle, "A", 10.0, 10.0, 50.0, 50.0).unwrap();
let b = d.create(f, NodeKind::Rectangle, "B", 30.0, 30.0, 50.0, 50.0).unwrap();
let outside = d.create(f, NodeKind::Rectangle, "Out", 90.0, 90.0, 50.0, 50.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(40.0, 40.0), 0.0), Some(b));
assert_eq!(d.hit_test(p, Point::new(15.0, 15.0), 0.0), Some(a));
assert_eq!(d.hit_test(p, Point::new(95.0, 95.0), 0.0), Some(outside));
assert_eq!(d.hit_test(p, Point::new(40.0, 40.0), 0.0, None), Some(b));
assert_eq!(d.hit_test(p, Point::new(15.0, 15.0), 0.0, None), Some(a));
assert_eq!(d.hit_test(p, Point::new(95.0, 95.0), 0.0, None), Some(outside));
// Clipped part of `outside` isn't hit.
assert_eq!(d.hit_test(p, Point::new(120.0, 120.0), 0.0), None);
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0), Some(f));
assert_eq!(d.hit_test(p, Point::new(120.0, 120.0), 0.0, None), None);
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f));
}
#[test]
@@ -132,7 +132,7 @@ fn click_target_rules() {
let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
let r = d.create(g, NodeKind::Rectangle, "R", 10.0, 10.0, 20.0, 20.0).unwrap();
d.fit_to_children(g).unwrap();
let leaf = d.hit_test(p, Point::new(15.0, 15.0), 0.0).unwrap();
let leaf = d.hit_test(p, Point::new(15.0, 15.0), 0.0, None).unwrap();
assert_eq!(leaf, r);
// Child of a top-level frame: the group.
assert_eq!(geometry::select_target(&d, leaf, &[], false), g);
+16 -2
View File
@@ -3,7 +3,9 @@
//! - **Selecting operands.** A click selects the boolean group; a double click (or Cmd/Ctrl-click,
//! or a click while a sibling operand is selected) selects the operand under the pointer, and a
//! double click on a selected shape operand enters vector edit mode on it. Enter selects every
//! operand.
//! operand. Clicks hit a boolean group where its result is (a click in a subtracted hole goes
//! through to what's behind), except while it or a layer in it is selected: then its operands
//! are hit, so each can be picked ([`result_outline`]).
//! - **Live results.** Moving, resizing or editing the points of an operand changes the document
//! on every pointer move, so the group's result is redrawn during the drag (one undo step per
//! gesture); a dragged operand stays in its group. The renderer caches every prefix of the group's fold, so only the operands from the
@@ -15,7 +17,8 @@
//! group's (and every operand follows it again); otherwise the selection is wrapped in a new
//! boolean group ([`apply`]).
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind};
use figcraft_doc::{BoolOp, Document, Node, NodeId, NodeKind};
use figcraft_geom::BezPath;
use serde_json::{Value, json};
use crate::params::req_targets;
@@ -62,6 +65,17 @@ pub fn shown_op(doc: &Document, ids: &[NodeId]) -> Option<BoolOp> {
ops.all(|o| o == Some(first)).then_some(first)
}
/// Boolean group results for hit testing ([`figcraft_doc::geometry::BoolResult`]): none for a
/// group that is selected or holds a selected layer, whose operands are hit instead.
pub fn result_outline<'a>(doc: &'a Document, selection: &'a [NodeId]) -> impl Fn(NodeId, &Node) -> Option<BezPath> + 'a {
move |id, n| {
if selection.iter().any(|s| doc.is_ancestor(id, *s)) {
return None;
}
figcraft_render::node_outline(doc, n).map(|(p, _)| p)
}
}
/// The operands to outline on the canvas while layers inside a boolean group are selected or
/// edited (`editing`: the layer in vector edit mode): every operand of the nearest boolean group
/// holding each of them, in layer order, without repeats.
+107
View File
@@ -0,0 +1,107 @@
//! Canvas fixes: boolean groups are clicked where their result is (a click in a subtracted hole
//! goes through), and a layer dragged inside a group stays in the group while frames still take
//! drops.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> NodeId {
id_of(&s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))["id"]).unwrap_or(NodeId(0))
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
s.pointer(&ev(PointerKind::Down, from.0, from.1, false)).unwrap();
for k in 1..=4 {
let t = f64::from(k) / 4.0;
s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t, false)).unwrap();
}
s.pointer(&ev(PointerKind::Up, to.0, to.1, false)).unwrap();
}
fn no_snapping(s: &mut Session) {
let ds = s.ds_mut().unwrap();
ds.view.snap_objects = false;
ds.view.snap_pixel = false;
}
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
s.doc().unwrap().node(id).unwrap().parent
}
/// A 200 × 200 ring (a square minus a square hole at 50..150) over a rectangle filling the hole,
/// and nothing behind the ring's top-right corner: (behind, ring, outer, hole).
fn ring_over_a_rectangle(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let behind = ex(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 80, "height": 80}));
let outer = ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
let hole = ex(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}));
let ring = ex(s, "bool.subtract", json!({"ids": [outer, hole]}));
s.execute("select.none", &json!({})).unwrap();
no_snapping(s);
(behind, ring, outer, hole)
}
#[test]
fn a_click_in_a_booleans_hole_goes_through_to_what_is_behind() {
let mut s = Session::new();
let (behind, ring, outer, hole) = ring_over_a_rectangle(&mut s);
click(&mut s, 100.0, 100.0, false);
assert_eq!(s.selection(), vec![behind], "the shape seen through the hole");
// (Away from the selected rectangle's corner handles.)
s.execute("select.none", &json!({})).unwrap();
click(&mut s, 145.0, 55.0, false);
assert!(s.selection().is_empty(), "the hole's corner, with nothing behind it: nothing");
click(&mut s, 25.0, 25.0, false);
assert_eq!(s.selection(), vec![ring], "the ring itself");
// Operand rules: Cmd/Ctrl-click reaches the operand under the pointer, and inside the group
// (it or an operand selected) every operand can be picked, the hole's too.
click(&mut s, 25.0, 25.0, true);
assert_eq!(s.selection(), vec![outer]);
click(&mut s, 100.0, 100.0, false);
assert_eq!(s.selection(), vec![hole], "a sibling operand, in the hole");
s.execute("select.set", &json!({"ids": [ring]})).unwrap();
s.pointer(&ev(PointerKind::DoubleClick, 100.0, 100.0, false)).unwrap();
assert_eq!(s.selection(), vec![hole], "a double click enters the group");
// "Select layer" doesn't list the ring in its hole.
s.execute("select.none", &json!({})).unwrap();
let listed = s.execute("select.layersAt", &json!({"x": 100, "y": 100})).unwrap();
let ids: Vec<NodeId> = listed["layers"].as_array().unwrap().iter().filter_map(|l| id_of(&l["id"])).collect();
assert_eq!(ids, vec![behind]);
}
#[test]
fn a_layer_dragged_in_a_group_stays_in_it() {
let mut s = Session::new();
let frame = ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 300}));
let a = ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
let b = ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}));
let g = ex(&mut s, "object.group", json!({"ids": [a, b]}));
no_snapping(&mut s);
// Into the frame, then out onto the empty page: the layer stays in its group each time.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
drag(&mut s, (25.0, 25.0), (525.0, 125.0));
assert_eq!(parent(&s, a), Some(g), "dropped over a frame: still in its group");
assert_eq!(s.doc().unwrap().world_bounds(a).unwrap().x0, 500.0, "it moved");
drag(&mut s, (525.0, 125.0), (225.0, 425.0));
assert_eq!(parent(&s, a), Some(g), "dropped on the page: still in its group");
let gb = s.doc().unwrap().world_bounds(g).unwrap();
assert_eq!((gb.x0, gb.y0, gb.x1, gb.y1), (100.0, 0.0, 250.0, 450.0), "the group's box follows its layers");
// A layer that isn't in a group still drops into the frame.
s.execute("select.set", &json!({"ids": [g]})).unwrap();
s.execute("object.ungroup", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
drag(&mut s, (225.0, 425.0), (525.0, 125.0));
assert_eq!(parent(&s, a), Some(frame), "outside a group, a frame takes the drop");
}
+3 -1
View File
@@ -658,7 +658,9 @@ fn layers_at(s: &mut Session, p: &Value) -> Result<Value> {
let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?);
let doc = s.doc()?;
let zoom = s.ds()?.view.zoom.max(1e-6);
let layers: Vec<Value> = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom)
let sel = s.selection();
let result = crate::booleans::result_outline(doc, &sel);
let layers: Vec<Value> = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result))
.into_iter()
.filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth})))
.collect();
+8 -1
View File
@@ -159,6 +159,13 @@ pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) -
doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, false, 0))
}
/// Whether layers dragged on the canvas inside `parent` stay in it (they only move): those of a
/// group or a boolean group (which follow their layers) and of an instance outside its slots.
pub fn keeps_layers(doc: &Document, parent: NodeId) -> bool {
matches!(doc.node(parent).map(|n| &n.kind), Some(NodeKind::Group | NodeKind::BooleanOp { .. }))
|| crate::components::structure_locked(doc, parent)
}
/// Margin (page units) around the auto layout frame a dragged layer comes from that still keeps it.
const HOME_MARGIN: f64 = 8.0;
@@ -201,7 +208,7 @@ pub fn swap_family(doc: &Document, component: NodeId) -> Option<String> {
/// under the point (innermost first; never one inside the dropped component `exclude`), the first
/// showing a variant of the same component set (`family`), else the innermost.
pub fn swap_target(doc: &Document, page: NodeId, p: Point, family: Option<&str>, exclude: Option<NodeId>) -> Option<NodeId> {
let hit = doc.hit_test(page, p, 0.0)?;
let hit = doc.hit_test(page, p, 0.0, Some(&crate::booleans::result_outline(doc, &[])))?;
let chain: Vec<NodeId> = std::iter::once(hit)
.chain(doc.ancestors(hit))
.filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Instance { .. })))
+1 -1
View File
@@ -40,7 +40,7 @@ fn pressing_a_rotated_frames_label_selects_and_drags_the_frame() {
let c = center(&q);
let page = s.page().unwrap();
// The label lies outside the frame: nothing else is there.
assert_eq!(s.doc().unwrap().hit_test(page, c, 1.0), None);
assert_eq!(s.doc().unwrap().hit_test(page, c, 1.0, None), None);
// Hover highlights the frame.
s.pointer(&ev(PointerKind::Hover, c)).unwrap();
assert_eq!(s.ds().unwrap().hover, Some(f));
+2
View File
@@ -1191,6 +1191,8 @@ mod branch_tests;
#[cfg(test)]
mod brush_tests;
#[cfg(test)]
mod canvas_fixes_tests;
#[cfg(test)]
mod code_connect_tests;
#[cfg(test)]
mod collab_ids_tests;
+2 -2
View File
@@ -37,7 +37,7 @@ fn hidden_contents_skip_rendering_and_hit_testing() {
let r = id(s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": sec})).unwrap());
s.execute("node.set", &json!({"id": r, "props": {"fills": ["#FF0000"]}})).unwrap();
let page = s.page().unwrap();
assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0), Some(r));
assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0, None), Some(r));
assert_eq!(pixel(&s, sec, 60, 60), [255, 0, 0, 255]);
// Hiding: the selected child gives way to the section.
s.execute("select.set", &json!({"ids": [r]})).unwrap();
@@ -45,7 +45,7 @@ fn hidden_contents_skip_rendering_and_hit_testing() {
assert_eq!(v["hidden"], json!(true));
assert_eq!(s.selection(), vec![sec]);
assert_eq!(get(&mut s, sec)["sectionContentsHidden"], json!(true));
assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0), Some(sec));
assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0, None), Some(sec));
assert_eq!(pixel(&s, sec, 60, 60), [255, 255, 255, 255], "the child isn't drawn");
// Toggling again shows them; explicit `hidden` sets.
s.execute("section.toggleContents", &json!({"ids": [sec]})).unwrap();
+1 -1
View File
@@ -534,7 +534,7 @@ impl Session {
fn builder_select(&mut self, ev: &PointerEvent) {
let Ok(ds) = self.ds() else { return };
let (doc, page, mut sel) = (ds.doc.clone(), ds.page, ds.selection.clone());
let Some(leaf) = doc.hit_test(page, ev.p(), 2.0 / ev.zoom.max(1e-6)) else { return };
let Some(leaf) = self.hit(&doc, page, ev.p(), 2.0 / ev.zoom.max(1e-6)) else { return };
let t = figcraft_doc::geometry::select_target(&doc, leaf, &sel, ev.cmd);
if ev.shift {
if let Some(i) = sel.iter().position(|x| *x == t) {
+10 -10
View File
@@ -1053,10 +1053,10 @@ impl Session {
let p = ev.p();
let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent);
// Absolute children of an auto layout frame stay in it, and so do the layers of
// an instance (outside its slots: they only move) and the operands of a boolean
// group (its result follows them); the rest may change parent.
// groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the
// rest may change parent.
let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute))
&& !home.is_some_and(|h| crate::components::structure_locked(doc, h) || crate::booleans::is_group(doc, h));
&& !home.is_some_and(|h| crate::drop::keeps_layers(doc, h));
let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) };
let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode);
if let (true, Some(mode)) = (flow, mode) {
@@ -1449,11 +1449,9 @@ impl Session {
let before = ds.doc.clone();
let d = ds.doc_mut();
for m in &moving {
// Instance layers (outside slots) and boolean operands stay put: they only move.
// Group, boolean group and instance layers stay put: they only move.
let from = d.node(*m).and_then(|n| n.parent);
if from.is_some_and(|f| crate::components::structure_locked(d, f) || crate::booleans::is_group(d, f))
|| crate::components::structure_locked(d, target)
{
if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) {
continue;
}
// A layer can't go into itself; it stays where it is.
@@ -1816,11 +1814,13 @@ impl Session {
}
/// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused
/// frame's.
/// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]).
pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option<NodeId> {
let sel = self.selection();
let result = crate::booleans::result_outline(doc, &sel);
match self.ds().ok().and_then(crate::DocState::focused) {
Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol),
None => doc.hit_test(page, p, tol),
Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)),
None => doc.hit_test(page, p, tol, Some(&result)),
}
}
+14 -4
View File
@@ -785,10 +785,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
for id in operands.iter().filter(|o| !sel.contains(o)) {
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0));
}
// Mask outlines: a mask doesn't draw itself.
if view.mask_outlines {
for id in doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.is_mask && n.visible)) {
draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0));
// Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows.
if view.mask_outlines || view.frame_outlines || view.show_slices {
for id in doc.subtree(page) {
let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
if view.mask_outlines && n.is_mask {
draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0));
} else if view.frame_outlines && n.kind.is_frame_like() {
draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0));
} else if view.show_slices
&& matches!(n.kind, NodeKind::Slice)
&& let Some(q) = node_quad(app, &doc, id)
{
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
}
}
}
// Drop target / insertion indicator.
+2 -2
View File
@@ -15,7 +15,7 @@ use crate::layers_drag_tests::Ui;
fn settle(u: &mut Ui) {
for _ in 0..300 {
u.frame(vec![]);
if u.app.worker.as_ref().is_some_and(|w| !w.in_flight) {
if u.app.worker.as_ref().is_some_and(|w| !w.in_flight()) {
return;
}
std::thread::sleep(Duration::from_millis(10));
@@ -56,6 +56,6 @@ fn a_drag_shows_on_the_canvas_while_it_moves() {
// no new documents, so the worker runs dry.
settle(&mut u);
assert!(Arc::ptr_eq(&held, &u.app.session.ds().unwrap().doc));
assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight), "nothing left to render");
assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight()), "nothing left to render");
u.button(p + vec2(30.0, 10.0), false);
}
+91
View File
@@ -0,0 +1,91 @@
//! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off
//! by default), and the render worker counts a job as in flight until it is done.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use egui::{Pos2, Shape};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::live_tests::flat;
use crate::present_tests::id;
/// Whether `shapes` hold a closed outline through the corners `q`.
fn outlined(shapes: &[Shape], q: [Pos2; 4]) -> bool {
shapes.iter().any(|s| matches!(s, Shape::Path(p) if p.closed && p.points.len() == 4 && p.points.iter().zip(q).all(|(a, b)| a.distance(b) < 0.5)))
}
/// How many line segments lie along the top edge from `a` to `b` (a dashed outline's dashes).
fn dashes_along(shapes: &[Shape], a: Pos2, b: Pos2) -> usize {
let on = |p: Pos2| (p.y - a.y).abs() < 0.5 && p.x >= a.x - 0.5 && p.x <= b.x + 0.5;
shapes.iter().filter(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if on(*p) && on(*q))).count()
}
#[test]
fn frame_outlines_and_slices_draw_when_shown() {
let mut s = Session::new();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
let inner = id(&s.execute("frame.create", &json!({"x": 20, "y": 20, "width": 80, "height": 60, "parent": f})).unwrap());
let slice = id(&s.execute("node.create", &json!({"type": "slice", "x": 300, "y": 0, "width": 100, "height": 50})).unwrap());
s.execute("select.set", &json!({"ids": []})).unwrap();
let mut u = Ui::new(s);
let quad = |u: &Ui, i| crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), i).unwrap();
let shapes = flat(&u.frame_output(vec![]).shapes);
let sq = quad(&u, slice);
assert!(!outlined(&shapes, quad(&u, inner)), "off by default");
assert_eq!(dashes_along(&shapes, sq[0], sq[1]), 0, "slices hidden by default");
u.app.run("view.frameOutlines", json!({}));
u.app.run("view.slices", json!({}));
let shapes = flat(&u.frame_output(vec![]).shapes);
for frame in [f, inner] {
assert!(outlined(&shapes, quad(&u, frame)), "every frame is outlined");
}
assert!(dashes_along(&shapes, sq[0], sq[1]) > 3, "the slice shows as a dashed rectangle");
u.app.run("view.frameOutlines", json!({"on": false}));
u.app.run("view.slices", json!({"on": false}));
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again");
}
/// A job a few hundred milliseconds of rendering big.
fn big_job(doc: &Arc<figcraft_doc::Document>, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job {
crate::render_worker::Job {
doc: doc.clone(),
page,
view: figcraft_render::View { origin: (0.0, 0.0), scale, width: 2400, height: 2400 },
outline: false,
outlined: vec![],
skip: vec![],
solo: None,
}
}
#[test]
fn a_job_queued_behind_a_finished_one_stays_in_flight() {
let ctx = egui::Context::default();
let wakes = Arc::new(AtomicUsize::new(0));
let w = wakes.clone();
ctx.set_request_repaint_callback(move |_| {
w.fetch_add(1, Ordering::SeqCst);
});
let mut s = Session::new();
for k in 0..40 {
s.execute("shape.ellipse", &json!({"x": k * 50, "y": k * 50, "width": 400, "height": 400})).unwrap();
}
let ds = s.ds().unwrap();
let mut worker = crate::render_worker::Worker::new(ctx.clone());
worker.submit(big_job(&ds.doc, ds.page, 1.0));
let start = Instant::now();
while wakes.load(Ordering::SeqCst) == 0 {
assert!(start.elapsed() < Duration::from_secs(60), "the first render never finished");
std::thread::sleep(Duration::from_millis(2));
}
// As in a canvas frame: a new job goes out, then the finished one is picked up.
worker.submit(big_job(&ds.doc, ds.page, 0.9));
assert!(worker.poll().is_some(), "the first image");
// Not in flight only once the second is done: its image is then waiting.
assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there");
}
+14 -7
View File
@@ -44,20 +44,14 @@ impl Harness {
/// row asking for no repaint sooner than [`IDLE`]. Returns the last frame's delay; fails with
/// what kept asking for frames.
fn settle(&mut self) -> Duration {
let start = Instant::now();
let mut quiet = 0;
for _ in 0..2000 {
let delay = self.frame(vec![]);
let rendering = self.app.worker.as_ref().is_some_and(|w| w.in_flight);
if rendering {
// The render thread wakes the UI when it's done.
std::thread::sleep(Duration::from_millis(2));
}
let rendering = wait_for_render(&self.app);
quiet = if delay >= IDLE && !rendering { quiet + 1 } else { 0 };
if quiet >= 3 {
return delay;
}
assert!(start.elapsed() < Duration::from_secs(20), "the UI never settled");
}
panic!("still asking for frames after 2000 with no input: {:?}", self.ctx.repaint_causes());
}
@@ -78,6 +72,19 @@ impl Harness {
}
}
/// Waits (without drawing frames, however slow the machine) until the render thread is done with
/// every job and has woken the UI for it; returns whether one was in flight.
pub(crate) fn wait_for_render(app: &FigApp) -> bool {
let start = Instant::now();
let mut waited = false;
while app.worker.as_ref().is_some_and(crate::render_worker::Worker::in_flight) {
assert!(start.elapsed() < Duration::from_secs(120), "the render thread never finished");
std::thread::sleep(Duration::from_millis(2));
waited = true;
}
waited
}
/// A page with a rectangle, an ellipse and a text layer; returns the text's id.
fn document() -> (Harness, Value) {
let mut s = Session::new();
+2
View File
@@ -63,6 +63,8 @@ mod branch_ui_tests;
#[cfg(test)]
mod canvas_drag_tests;
#[cfg(test)]
mod canvas_fixes_tests;
#[cfg(test)]
mod comments_tests;
#[cfg(test)]
mod dev_mode_tests;
+1 -4
View File
@@ -148,10 +148,7 @@ impl Harness {
pub(crate) fn goes_quiet(&mut self) -> bool {
let mut quiet = 0;
for _ in 0..200 {
let rendering = self.app.worker.as_ref().is_some_and(|w| w.in_flight);
if rendering {
std::thread::sleep(Duration::from_millis(2));
}
let rendering = crate::idle_tests::wait_for_render(&self.app);
let (_, d) = self.frame(vec![]);
quiet = if d >= Duration::from_secs(1) && !rendering { quiet + 1 } else { 0 };
if quiet >= 3 {
+8 -7
View File
@@ -515,13 +515,14 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens)
let p = app.to_page(pos);
let Ok(ds) = app.session.ds() else { return false };
let tol = 2.0 / ds.view.zoom.max(1e-6);
let layers: Vec<(figcraft_doc::NodeId, usize, String)> = figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol)
.into_iter()
.take(40)
.filter_map(|(id, depth)| {
ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() }))
})
.collect();
let layers: Vec<(figcraft_doc::NodeId, usize, String)> =
figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol, Some(&figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection)))
.into_iter()
.take(40)
.filter_map(|(id, depth)| {
ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() }))
})
.collect();
if layers.is_empty() {
return false;
}
+25 -15
View File
@@ -66,22 +66,28 @@ fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
#[cfg(not(target_arch = "wasm32"))]
pub struct Worker {
tx: std::sync::mpsc::Sender<Job>,
tx: std::sync::mpsc::Sender<(u64, Job)>,
rx: std::sync::mpsc::Receiver<Done>,
pub in_flight: bool,
/// Jobs submitted so far.
submitted: u64,
/// The newest job the thread is done with: rendered (or skipped for a newer one) and the UI
/// woken for it.
finished: Arc<std::sync::atomic::AtomicU64>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Worker {
pub fn new(ctx: egui::Context) -> Self {
let (tx, jobs) = std::sync::mpsc::channel::<Job>();
let (tx, jobs) = std::sync::mpsc::channel::<(u64, Job)>();
let (done_tx, rx) = std::sync::mpsc::channel::<Done>();
let finished = Arc::new(std::sync::atomic::AtomicU64::new(0));
let done_with = finished.clone();
let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || {
let mut retained = Retained::default();
while let Ok(mut job) = jobs.recv() {
while let Ok((mut seq, mut job)) = jobs.recv() {
// Skip to the newest queued job.
while let Ok(newer) = jobs.try_recv() {
job = newer;
(seq, job) = newer;
}
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained)));
let done = match res {
@@ -98,28 +104,32 @@ impl Worker {
break;
}
ctx.request_repaint();
done_with.store(seq, std::sync::atomic::Ordering::Release);
}
});
if let Err(e) = spawned {
log::error!("render thread: {e}");
}
Self { tx, rx, in_flight: false }
Self { tx, rx, submitted: 0, finished }
}
pub fn submit(&mut self, job: Job) {
if self.tx.send(job).is_ok() {
self.in_flight = true;
if self.tx.send((self.submitted + 1, job)).is_ok() {
self.submitted += 1;
}
}
/// Whether a submitted job is still rendering or hasn't woken the UI yet (a finished image
/// may still wait for [`Self::poll`]).
pub fn in_flight(&self) -> bool {
self.finished.load(std::sync::atomic::Ordering::Acquire) < self.submitted
}
pub fn poll(&mut self) -> Option<Done> {
let mut last = None;
while let Ok(d) = self.rx.try_recv() {
last = Some(d);
}
if last.is_some() {
self.in_flight = false;
}
last
}
}
@@ -128,21 +138,21 @@ impl Worker {
pub struct Worker {
pending: Option<Job>,
retained: Retained,
pub in_flight: bool,
}
#[cfg(target_arch = "wasm32")]
impl Worker {
pub fn new(_ctx: egui::Context) -> Self {
Self { pending: None, retained: Retained::default(), in_flight: false }
Self { pending: None, retained: Retained::default() }
}
pub fn submit(&mut self, job: Job) {
self.pending = Some(job);
self.in_flight = true;
}
pub fn in_flight(&self) -> bool {
self.pending.is_some()
}
pub fn poll(&mut self) -> Option<Done> {
let job = self.pending.take()?;
self.in_flight = false;
run(&job, &mut self.retained)
}
}
+9 -7
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; 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
@@ -26,7 +26,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 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) |
| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
| 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) |
| 14 | Variables: extended collections' tail | Feature | 1–2 | [components-parity](components-parity.md) |
| 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) |
| 16 | Dev Mode: dev resources links, editor extension (code connect and Dev Mode plugins landed) | Feature | 1–3 | [dev-mode-parity](dev-mode-parity.md) |
@@ -176,17 +176,18 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework.
- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock).
### 13. Boolean groups hit-tested by their operands
- **Landed (2026-10-11):** live boolean editing (operands selected by double-click or Cmd/Ctrl-click;
### 13. Vector tail: none left
- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a
subtracted hole reaches what's behind; with the group or an operand selected its operands are
hit, so each can still be picked; "Select layer" lists the group only where its result is), live
boolean editing (operands selected by double-click or Cmd/Ctrl-click;
moving, resizing or editing an operand's points redraws the result during the drag, with every
operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame
recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep,
Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take
part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG,
SVG, PDF and outline stroke).
- **Missing:** a click is tested against a boolean group's operands, not its result, so a click in
a subtracted hole still picks the group.
- **Estimate:** 1–3 h.
- **Estimate:** none left (vector QA with real files is part of gap 3).
### 14. Variables: extended collections' tail
- **Landed:** extended collections (`library.extendCollection`: a library collection's modes and
@@ -274,6 +275,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 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 |
+4 -2
View File
@@ -137,7 +137,7 @@ Or in a project's `.mcp.json`:
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window when attached. |
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
| `undo` / `redo` | Step through history. |
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, 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). |
| `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`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `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). |
@@ -427,7 +427,9 @@ on any operand, or `booleanOperation` on an operand that isn't itself a boolean
Operands stay editable layers: `select.children` (Enter) or a double-click selects them, `cmd: true`
clicks reach them directly, and moving, resizing or editing an operand's points (`vector.edit.*`)
updates the group's result on every pointer move, one undo step per gesture; a dragged operand
stays in its group. `bool.flatten` bakes the result. Only the operands from the edited one up are
stays in its group. Clicks and `select.layersAt` hit a boolean group where its result is (a click in
a subtracted hole reaches what's behind it); while the group or one of its operands is selected, its
operands are hit instead, so each can be picked. `bool.flatten` bakes the result. Only the operands from the edited one up are
recombined during a drag (`figcraft_render::boolean`; timed by `cargo xtask bench`).
### Shape builder
+3 -2
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; 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 | ~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 |
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit | 7–18 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
| M6 Interop & Dev Mode | ~55% (code connect and Dev Mode plugins landed 2026-10-11) | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 37–75 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
@@ -72,6 +72,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 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 |
+5 -4
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; 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
@@ -97,7 +97,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 |
| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 |
| 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 |
| 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 |
| 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 landed, see 20) | 1–3 |
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
@@ -258,9 +258,9 @@ Hours in this narrative are the builders' estimates; the tables above supersede
boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or
editing an operand's points redraws the result during the drag with every operand outlined;
per-operand operations from the boolean controls; only the operands from the edited one up are
recombined), the shape builder on strokes and open paths (strokes as their outlined areas), and
recombined; clicks hit a boolean group where its result is), the shape builder on strokes and open paths (strokes as their outlined areas), and
one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke).
*Left:* boolean groups are hit-tested by their operands rather than their result. 1–3 h.
*Left:* vector QA with real files. 1–2 h.
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
truncation/max lines, font picker with previews and recents, word/line selection, IME commit,
inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the
@@ -578,6 +578,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 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 |
+6 -3
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -56,7 +56,7 @@ interaction feel not yet compared.
| 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 |
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | |
| Text: point, auto height, fixed; caret, words, lines, IME | done | no spell check |
| Eyedropper (I) | done | |
| Comment (C), Shift+C to show/hide | done | local only |
@@ -71,7 +71,9 @@ interaction feel not yet compared.
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | |
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | |
| Frame labels (rotated, right-to-left), size badge | done | |
| Outline mode | done | Outlines, outlines for the selection, mask outlines |
| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines |
| Slices shown on the canvas (View ▸ Slices) | done | dashed rectangles |
| Dragging a layer inside a group keeps it in the group | done | frames and auto layout take other drops |
| Layout grids (Ctrl+G) | done | |
| Gradient and padding/gap handles on the canvas | done | |
| Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots |
@@ -92,6 +94,7 @@ interaction feel not yet compared.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw |
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |