Merge remote-tracking branch 'origin/main' into switch/craft-fig-rebased

# Conflicts:
#	docs/file-format-parity.md
This commit is contained in:
Dylan M. Taylor committed 2026-11-12 06:46:19 +00:00
commit 0d541f4e11
42 files changed
+1592 -86

No files matched your search

+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a text style binds a number variable to its weight (#2174); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their linksLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); a text style binds a number variable to its weight (#2174); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server cLine truncated
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
@@ -365,6 +365,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result. Numbers unchanged |
| 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. `board.mindMap` places a first node with three children on curved branches and `board.mindMapAdd` adds a child or a sibling; the normalize pass lays a touched map out again from its first node (88 between columns, 48 between siblings, stacks centred on their parents). No toolbar entry yet; numbers unchanged. |
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it while an explicit Hans stays Simplified; the desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled; native review pending. Numbers unchanged |
| 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged |
+8 -4
View File
@@ -395,7 +395,8 @@ impl NodeKind {
)
}
/// Kinds whose corners can be rounded from the panel.
/// Kinds whose corners can be rounded from the panel. A boolean group has one radius for every
/// sharp corner of its result.
pub fn has_corner_radius(&self) -> bool {
matches!(
self,
@@ -408,16 +409,19 @@ impl NodeKind {
| NodeKind::Star { .. }
| NodeKind::Vector { .. }
| NodeKind::Section { .. }
| NodeKind::BooleanOp { .. }
)
}
/// Kinds with a radius per corner (the others round every corner alike).
/// Kinds with a radius per corner (the others, boolean groups included, round every corner alike).
pub fn has_independent_corners(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
self.has_corner_radius()
&& !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::BooleanOp { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network
/// has a closed region (an open path's rounded points are drawn without it).
/// has a closed region (an open path's rounded points are drawn without it). A boolean group's
/// smoothing applies to its result's corners between straight edges.
pub fn has_corner_smoothing(&self) -> bool {
match self {
NodeKind::Vector { network } => !network.regions.is_empty(),
+21
View File
@@ -103,3 +103,24 @@ fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_s
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark"));
}
#[test]
fn the_invert_zoom_preference_toggles_and_is_saved() {
let mut s = Session::new();
assert!(!s.prefs.invert_zoom, "off by default");
assert_eq!(s.execute("pref.invertZoom", &json!({})).unwrap()["on"], true);
assert!(s.prefs.invert_zoom);
let saved = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["invert_zoom"], true);
let loaded: crate::Prefs = serde_json::from_value(saved).unwrap();
assert!(loaded.invert_zoom);
assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false);
assert!(!s.prefs.invert_zoom);
let saved = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["invert_zoom"], false);
let loaded: crate::Prefs = serde_json::from_value(saved).unwrap();
assert!(!loaded.invert_zoom, "off persists after being on");
// Preferences saved before the setting existed read it as off.
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.invert_zoom);
}
+134
View File
@@ -0,0 +1,134 @@
//! A boolean group's corner radius through the engine: it rounds the result (and the SVG export
//! of it), children keep their own radius, Flatten bakes the arcs and clicks follow the result.
use serde_json::json;
use crate::Session;
use crate::booleans_tests::{area, click, ex};
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
use figcraft_geom::{Point, Shape};
const L_AREA: f64 = 34656.6;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"]).unwrap()
}
fn snapping_off(s: &mut Session) {
ex(s, "select.none", json!({}));
let ds = s.ds_mut().unwrap();
ds.view.snap_objects = false;
ds.view.snap_pixel = false;
}
fn set_radius(s: &mut Session, id: NodeId, r: f64) {
ex(s, "node.set", json!({"ids": [id], "props": {"cornerRadius": r}}));
}
/// The L of two rectangles, united, nothing selected, snapping off.
fn l_group(s: &mut Session) -> NodeId {
let (a, b) = (rect(s, 0.0, 0.0, 200.0, 100.0), rect(s, 150.0, 0.0, 100.0, 200.0));
let g = id_of(&ex(s, "bool.union", json!({"ids": [a, b]}))["id"]).unwrap();
snapping_off(s);
g
}
/// Whether the page point is inside the group's drawn result.
fn covers(s: &Session, g: NodeId, x: f64, y: f64) -> bool {
let doc = s.doc().unwrap();
let (path, _) = figcraft_render::node_outline(doc, doc.node(g).unwrap()).unwrap();
let p = doc.world_transform(g).inverse() * Point::new(x, y);
path.winding(p) % 2 != 0
}
#[test]
fn a_boolean_group_rounds_every_sharp_corner_of_its_result() {
let mut s = Session::new();
let g = l_group(&mut s);
assert!(covers(&s, g, 1.0, 1.0));
set_radius(&mut s, g, 20.0);
assert!(!covers(&s, g, 1.0, 1.0) && covers(&s, g, 148.0, 102.0));
assert!((area(&s, g) - L_AREA).abs() < 1.0, "{}", area(&s, g));
let doc = s.doc().unwrap();
let svg = figcraft_svg::export_svg(doc, &[g], &figcraft_svg::SvgOptions::default()).unwrap();
let d = svg.split("d=\"").nth(1).and_then(|r| r.split('"').next()).unwrap();
let exported = figcraft_geom::svg_path(d).unwrap();
assert!((exported.area().abs() - L_AREA).abs() < 1.0, "{}", exported.area());
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii, [0.0; 4], "one undo step");
assert!((area(&s, g) - 35000.0).abs() < 1.0);
}
/// The notch of the measurement: A (200 by 100 at the origin) minus a 100 square on its top edge,
/// whose own radius is `a_radius`, under a group radius of `radius`.
fn notch(s: &mut Session, a: NodeId, radius: f64) -> NodeId {
let b = rect(s, 50.0, -50.0, 100.0, 100.0);
let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap();
snapping_off(s);
set_radius(s, g, radius);
g
}
/// The asserts of the measurement on a notched group at radius 20 under A's own 10.
fn assert_notch_at_20(s: &Session, g: NodeId) {
assert!(covers(s, g, 4.0, 4.0), "A's 10 was not enlarged to 20");
assert!(!covers(s, g, 48.0, 2.0), "the cut's top corner rounds");
assert!(covers(s, g, 52.0, 48.0), "the cut's inner corner fills in");
}
#[test]
fn children_keep_their_radius_and_a_subtraction_rounds_its_cut() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
set_radius(&mut s, a, 10.0);
let g = notch(&mut s, a, 20.0);
assert_notch_at_20(&s, g);
assert_eq!(s.doc().unwrap().node(a).unwrap().corners.radii, [10.0; 4]);
set_radius(&mut s, g, 500.0);
assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii[0], 500.0, "the field keeps the value");
assert!(!covers(&s, g, 40.0, 4.0), "25, not the 20 that half the 40-long edge would give");
assert!(covers(&s, g, 27.0, 1.0), "and not more than 25");
assert!(covers(&s, g, 100.0, 51.0) && !covers(&s, g, 100.0, 49.0), "the floor stays straight between 75 and 125");
assert!(covers(&s, g, 4.0, 4.0));
}
#[test]
fn a_nested_group_keeps_its_own_radius() {
let mut s = Session::new();
let (l, r) = (rect(&mut s, 0.0, 0.0, 120.0, 100.0), rect(&mut s, 80.0, 0.0, 120.0, 100.0));
let inner = id_of(&ex(&mut s, "bool.union", json!({"ids": [l, r]}))["id"]).unwrap();
set_radius(&mut s, inner, 10.0);
let g = notch(&mut s, inner, 20.0);
assert_notch_at_20(&s, g);
}
#[test]
fn flatten_bakes_the_arcs_and_the_vector_has_no_radius() {
let mut s = Session::new();
let g = l_group(&mut s);
set_radius(&mut s, g, 20.0);
let flat = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [g]}))["id"]).unwrap();
let doc = s.doc().unwrap();
let n = doc.node(flat).unwrap();
let NodeKind::Vector { network } = &n.kind else { panic!("a vector: {:?}", n.kind) };
assert_eq!(n.corners.radii, [0.0; 4]);
assert!(network.vertices.iter().all(|v| v.radius == 0.0));
let path = figcraft_doc::geometry::shape_path(n).unwrap();
assert!((path.area().abs() - L_AREA).abs() < 1.0, "{}", path.area());
assert!((n.width - 250.0).abs() < 1e-6 && (n.height - 200.0).abs() < 1e-6);
}
#[test]
fn clicks_follow_the_rounded_result() {
let mut s = Session::new();
let g = l_group(&mut s);
set_radius(&mut s, g, 20.0);
click(&mut s, 1.0, 1.0, false);
assert!(s.selection().is_empty(), "the cut-off corner reaches what is behind");
click(&mut s, 148.0, 102.0, false);
assert_eq!(s.selection(), vec![g], "the concave fillet belongs to the group");
ex(&mut s, "select.none", json!({}));
click(&mut s, 100.0, 50.0, false);
assert_eq!(s.selection(), vec![g]);
}
+4 -4
View File
@@ -11,21 +11,21 @@ use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{BoolOp, NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
pub(crate) 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) {
pub(crate) 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();
}
/// A layer's filled area (a boolean group's result; none of these have holes).
fn area(s: &Session, id: NodeId) -> f64 {
pub(crate) fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs()
}
+3 -1
View File
@@ -32,7 +32,7 @@ use handles::DRAG_SLOP_PX;
pub(crate) use handles::scale_factor;
pub use handles::{
PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point,
selection_box, selection_frame,
selection_box, selection_frame, shared_rotation_degrees,
};
pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box};
#[cfg(test)]
@@ -454,6 +454,8 @@ pub(crate) enum Gesture {
Rotate {
center: Point,
start_angle: f64,
/// The rotation (Design panel convention) the layers share when the drag began, else 0.
base: f64,
ids: Vec<NodeId>,
},
Draw {
+6 -4
View File
@@ -1,6 +1,6 @@
use super::handles::{
SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame,
shape_hint,
shape_hint, shared_rotation_degrees,
};
use super::{
Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point,
@@ -58,7 +58,8 @@ impl Session {
.moved_origin()
.unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::<f64>() / 4.0, b.iter().map(|c| c.y).sum::<f64>() / 4.0));
self.begin_interaction("Rotate")?;
self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids };
self.tool.gesture =
Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), base: shared_rotation_degrees(&doc, &ids), ids };
return Ok(());
}
let nodes: Vec<(NodeId, Affine, f64, f64)> =
@@ -362,10 +363,11 @@ impl Session {
}
return res;
}
Gesture::Rotate { center, start_angle, ids } => {
Gesture::Rotate { center, start_angle, base, ids } => {
let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees();
if ev.shift {
a = (a / 15.0).round() * 15.0;
// The panel rotation is base - a; Shift lands it on a multiple of 15 degrees.
a = base - ((base - a) / 15.0).round() * 15.0;
}
let (c, ids) = (*center, ids.clone());
self.preview(|doc, _| {
@@ -132,6 +132,14 @@ pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f
Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height()))
}
/// The rotation `ids` share on the page (degrees, counter-clockwise positive), else 0 (the handle
/// box is page-aligned then). Figma's rotation field shows this page rotation, also for a layer
/// inside a rotated frame or group, and Shift lands it on multiples of 15 degrees. Axis-aligned
/// layers give 0, which is the same as 90, 180 and 270 for stepping in 15 degrees.
pub fn shared_rotation_degrees(doc: &Document, ids: &[NodeId]) -> f64 {
shared_rotation(doc, ids).map_or(0.0, |a| -a.to_degrees())
}
/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the
/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction.
fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option<f64> {
+101 -13
View File
@@ -10,6 +10,8 @@
pub mod ops;
pub(crate) mod point_snap;
mod pointer;
#[cfg(test)]
mod position_tests;
pub mod regions;
#[cfg(test)]
mod regions_tests;
@@ -706,7 +708,61 @@ fn handle_list(p: &Value, key: &str) -> Option<Vec<(usize, bool)>> {
}
}
/// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show).
/// One axis of `vector.edit.position`: left alone, set, or relative to each point's own value.
#[derive(Clone, Copy)]
enum AxisEdit {
Keep,
Set(f64),
Add(f64),
Mul(f64),
Div(f64),
}
impl AxisEdit {
fn apply(self, cur: f64) -> Result<f64> {
let v = match self {
AxisEdit::Keep => cur,
AxisEdit::Set(v) => v,
AxisEdit::Add(n) => cur + n,
AxisEdit::Mul(n) => cur * n,
AxisEdit::Div(n) if n != 0.0 => cur / n,
AxisEdit::Div(_) => return Err(bad("vector.edit.position", "cannot divide by zero")),
};
if v.is_finite() { Ok(v) } else { Err(bad("vector.edit.position", "the result is not a number")) }
}
}
/// Parse `p[key]`: absent is `Keep`, a number is `Set`, `{add|mul|div: n}` is relative; anything else is refused.
fn axis_edit(p: &Value, key: &str) -> Result<AxisEdit> {
let junk = || bad("vector.edit.position", format!("`{key}` must be a number or {{add|mul|div: number}}"));
match get(p, key) {
None => Ok(AxisEdit::Keep),
Some(Value::Object(o)) => {
let (Some((k, v)), 1) = (o.iter().next(), o.len()) else { return Err(junk()) };
let n = match v {
Value::Number(n) => n.as_f64().filter(|n| n.is_finite()),
_ => None,
}
.ok_or_else(junk)?;
match k.as_str() {
"add" => Ok(AxisEdit::Add(n)),
"mul" => Ok(AxisEdit::Mul(n)),
"div" if n != 0.0 => Ok(AxisEdit::Div(n)),
_ => Err(junk()),
}
}
Some(v) => f64_of(v).filter(|_| !v.is_boolean()).map(AxisEdit::Set).ok_or_else(junk),
}
}
/// The selected points in the parent's coordinates (what the X/Y fields show).
pub fn selected_parent_points(doc: &Document, ve: &VectorEdit) -> Vec<Point> {
let Some(net) = network(doc, ve.id) else { return Vec::new() };
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
selected_points(net, ve).into_iter().filter_map(|v| net.vertices.get(v)).map(|v| t * v.point()).collect()
}
/// Selection bounds' top-left in the parent's coordinates (the `x` and `y` of `vector.edit.bounds`).
pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option<Point> {
let net = network(doc, ve.id)?;
let t = doc.node(ve.id)?.transform;
@@ -935,18 +991,50 @@ pub fn specs() -> Vec<CommandSpec> {
edit_sets(s, sets, |net, v, w| ops::move_vertices(net, v, local_offset(w, d)))?;
Ok(json!({"ok": true}))
}),
spec!("vector.edit.position", "Point position", "{x?, y?} (the selected points' top-left, in the parent's coordinates)", true, |s, p| {
let ve = editing(s)?;
let doc = s.doc()?;
let cur = selection_position(doc, &ve).ok_or_else(|| bad("vector.edit.position", "select points"))?;
let target = Point::new(num_or(p, "x", cur.x), num_or(p, "y", cur.y));
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
let inv = t.inverse();
let d = inv * target - inv * cur;
let v = network(doc, ve.id).map(|n| selected_points(n, &ve)).unwrap_or_default();
edit(s.doc_mut()?, ve.id, |net| ops::move_vertices(net, &v, d))?;
Ok(json!({"ok": true}))
}),
spec!(
"vector.edit.position",
"Point position",
"{x?, y?}: each a number (every selected point's own coordinate becomes it) or {add: n}, {mul: n} or {div: n} (applied to each selected point's own coordinate); in the parent's coordinates",
true,
|s, p| {
let ve = editing(s)?;
let doc = s.doc()?;
let (ax, ay) = (axis_edit(p, "x")?, axis_edit(p, "y")?);
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
let inv = t.inverse();
let net = network(doc, ve.id).ok_or_else(|| bad("vector.edit.position", "select points"))?;
let mut moves: Vec<(usize, Vec2)> = Vec::new();
for i in selected_points(net, &ve) {
let Some(v) = net.vertices.get(i) else { continue };
let cur = t * v.point();
let target = Point::new(ax.apply(cur.x)?, ay.apply(cur.y)?);
let d = inv * target - v.point();
if !(d.x.is_finite() && d.y.is_finite()) {
return Err(bad("vector.edit.position", "the result is not a number"));
}
moves.push((i, d));
}
if moves.is_empty() {
return Err(bad("vector.edit.position", "select points"));
}
// Points that all share one offset move as one, so moving every point of a layer
// by one offset still carries the regions' paint frames, as a drag of all of them
// does. Otherwise no one move covers every point, so each point moves alone.
let d0 = moves[0].1;
let uniform = moves.iter().all(|(_, d)| (*d - d0).hypot() < 1e-9);
edit(s.doc_mut()?, ve.id, |net| {
if uniform {
let all: Vec<usize> = moves.iter().map(|(i, _)| *i).collect();
ops::move_vertices(net, &all, d0);
} else {
for (i, d) in &moves {
ops::move_vertices(net, &[*i], *d);
}
}
})?;
Ok(json!({"ok": true}))
}
),
spec!("vector.edit.setHandle", "Set handle", "{segment, start (bool), x, y (local), independent?}", true, |s, p| {
let ve = editing(s)?;
let seg = index("vector.edit.setHandle", p, "segment")?;
@@ -0,0 +1,171 @@
//! `vector.edit.position`: with several points, a number sets every selected point's own
//! coordinate, and `{add}`, `{mul}`, `{div}` apply to each point's own.
use serde_json::json;
use super::tests::{ex, net, rect};
use super::*;
use crate::Session;
/// Every point in the parent's coordinates (the layer re-fits its box after an edit, so its own
/// coordinates shift).
fn xs(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
let t = s.doc().unwrap().node(id).unwrap().transform;
net(s, id).vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect()
}
fn parent_xy(s: &Session) -> Vec<(f64, f64)> {
let ve = s.ds().unwrap().vector_edit.clone().unwrap();
selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect()
}
fn undo_steps(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn a_number_sets_every_selected_points_own_coordinate() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
// The rectangle's points 0 and 1 differ in x; both end at -200 and keep their y.
assert!(!near(before[0].0, before[1].0));
ex(&mut s, "vector.edit.position", json!({"x": -200}));
let after = xs(&s, id);
assert!(near(after[0].0, -200.0) && near(after[1].0, -200.0), "{after:?}");
assert!(near(after[0].1, before[0].1) && near(after[1].1, before[1].1));
assert_eq!(after[2], before[2]);
// One undo step restores.
assert!(s.undo().unwrap());
assert_eq!(xs(&s, id), before);
}
#[test]
fn one_point_sets_its_own_coordinates() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [2]}));
ex(&mut s, "vector.edit.position", json!({"x": 7, "y": 9}));
let after = xs(&s, id);
assert!(near(after[2].0, 7.0) && near(after[2].1, 9.0));
assert_eq!(after[0], before[0]);
}
#[test]
fn relative_edits_apply_to_each_points_own_value() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}}));
let a = xs(&s, id);
assert!(near(a[0].0, before[0].0 + 10.0) && near(a[1].0, before[1].0 + 10.0), "{a:?}");
ex(&mut s, "vector.edit.position", json!({"y": {"mul": 2}}));
let b = xs(&s, id);
assert!(near(b[0].1, a[0].1 * 2.0) && near(b[1].1, a[1].1 * 2.0), "{b:?}");
ex(&mut s, "vector.edit.position", json!({"x": {"div": 2}}));
let c = xs(&s, id);
assert!(near(c[0].0, a[0].0 / 2.0) && near(c[1].0, a[1].0 / 2.0), "{c:?}");
}
#[test]
fn a_point_keeps_its_handles_relative_to_it() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
ex(&mut s, "vector.edit.smooth", json!({}));
let before = net(&s, id);
assert!(before.segments.iter().any(|g| g.ta != (0.0, 0.0) || g.tb != (0.0, 0.0)));
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 25}}));
let after = net(&s, id);
let tangents = |n: &VectorNetwork| n.segments.iter().map(|g| (g.ta, g.tb)).collect::<Vec<_>>();
assert_eq!(tangents(&before), tangents(&after));
}
#[test]
fn the_values_are_in_the_parents_coordinates() {
let mut s = Session::new();
let r = ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 30, "width": 100, "height": 100}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
let before = parent_xy(&s);
assert!(near(before[0].0, 40.0), "{before:?}");
ex(&mut s, "vector.edit.position", json!({"x": 10}));
let after = parent_xy(&s);
assert!(near(after[0].0, 10.0) && near(after[1].0, 10.0), "{after:?}");
assert!(near(after[0].1, before[0].1));
}
#[test]
fn moving_every_point_by_one_offset_carries_the_regions_paint_frame() {
// `{add}` on every point is a plain translation: the region's paint frame goes with the
// points, as when they are dragged together.
let mut s = Session::new();
let id = rect(&mut s);
{
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected a vector") };
assert!(!network.regions.is_empty(), "the rectangle has a region");
for region in &mut network.regions {
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::IDENTITY });
}
}
let frame = |s: &Session| {
let world = s.doc().unwrap().world_transform(id);
world * net(s, id).regions[0].paint_space.as_ref().unwrap().transform
};
let before = frame(&s);
let all: Vec<usize> = (0..net(&s, id).vertices.len()).collect();
ex(&mut s, "vector.edit.select", json!({"vertices": all}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}}));
let (want, got) = ((Affine::translate((10.0, 0.0)) * before).as_coeffs(), frame(&s).as_coeffs());
assert!(want.iter().zip(got).all(|(a, b)| near(*a, b)), "{got:?} vs {want:?}");
}
#[test]
fn junk_is_refused_and_changes_nothing() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
let steps = undo_steps(&s);
for junk in [
json!({"x": {"div": 0}}),
json!({"x": {"pow": 2}}),
json!({"x": "a"}),
json!({"x": {"add": 1, "mul": 2}}),
json!({"y": {"add": "x"}}),
json!({"x": {}}),
json!({"x": [1]}),
json!({"x": true}),
json!({"x": {"add": true}}),
// A valid x with a refused y changes neither.
json!({"x": 5, "y": {"div": 0}}),
// Point 1's x of 100 overflows; point 0's 0 alone would not.
json!({"x": {"mul": 1e308}}),
] {
assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}");
}
assert_eq!(xs(&s, id), before);
assert_eq!(undo_steps(&s), steps, "a refused edit leaves no undo step");
}
#[test]
fn nothing_selected_is_refused() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.select", json!({"vertices": []}));
let (before, steps) = (xs(&s, id), undo_steps(&s));
assert!(parent_xy(&s).is_empty());
let err = s.execute("vector.edit.position", &json!({"x": 5})).unwrap_err();
assert!(err.to_string().contains("select points"), "{err}");
assert_eq!((xs(&s, id), undo_steps(&s)), (before, steps));
}
+7
View File
@@ -434,6 +434,13 @@ pub fn specs() -> Vec<CommandSpec> {
false,
|s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom)
),
spec!(
"pref.invertZoom",
"Invert zoom direction",
"{on?} reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in (a trackpad pinch and panning are unchanged) → {on}",
false,
|s, p| pref(s, p, |x| &mut x.invert_zoom)
),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
+2
View File
@@ -227,6 +227,8 @@ mod board_tools_tests;
#[cfg(test)]
mod boolean_cap_tests;
#[cfg(test)]
mod boolean_radius_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
+4
View File
@@ -36,6 +36,9 @@ pub struct Prefs {
/// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the
/// other way round). Trackpads are unchanged.
pub scroll_wheel_zoom: bool,
/// Wheel zoom goes the other way: Cmd/Ctrl+wheel (and a plain mouse wheel with Scroll wheel
/// zoom on) zooms out where it would zoom in. A trackpad pinch is unchanged.
pub invert_zoom: bool,
/// "system" by default; an explicit "light" or "dark" choice stays saved.
pub theme: String,
/// The widths of the main window's left and right panels (the interface keeps them within its
@@ -136,6 +139,7 @@ impl Default for Prefs {
number_keys_opacity: true,
show_dimensions: true,
scroll_wheel_zoom: false,
invert_zoom: false,
theme: "system".into(),
panel_widths: (240.0, 240.0),
pages_height: None,
@@ -189,6 +189,7 @@ fn corner_radius_follows_the_documented_rules() {
(NodeKind::Polygon { count: 5 }, true, false, "polygons: one radius for every corner"),
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, false, "stars: one radius for every corner"),
(NodeKind::Vector { network: Default::default() }, true, false, "vectors: a radius, and one per point in edit mode"),
(NodeKind::BooleanOp { op: BoolOp::Union }, true, false, "boolean groups: one radius for every sharp corner of the result"),
(NodeKind::Line, false, false, "lines and arrows take no radius"),
];
for (kind, radius, independent, why) in doc_rows {
@@ -241,6 +242,7 @@ fn corner_smoothing_follows_the_documented_rules() {
"vector networks that are closed shapes",
),
(NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"),
(NodeKind::BooleanOp { op: BoolOp::Union }, true, "boolean groups (the plugin API's corner mixin; the look is pinned)"),
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
];
for (kind, smoothing, why) in doc_rows {
@@ -369,6 +369,73 @@ fn resizing_and_flipping_one_layer() {
assert!(near(rotation(&s, t).abs(), 30.0), "Shift: the nearest 15° step: {}", rotation(&s, t));
}
/// A rotate-handle drag on a layer at `start` degrees, turning it by `turn` degrees in the Design
/// panel's convention (positive is counter-clockwise on screen); returns the rotation it ends at.
fn rotate_drag_from(start: f64, turn: f64, m: Mods) -> f64 {
let mut s = session();
let r = rect(&mut s, 0.0, 200.0, 100.0, 100.0, None);
set(&mut s, &[r], json!({"rotation": start}));
ex(&mut s, "select.set", json!({"ids": [r]}));
// The handle sits just outside the top-left corner of the turned box; the pointer then circles
// the centre by `turn` (the page angle runs the other way round, y being down).
let world = s.doc().unwrap().world_transform(r);
let (c, corner) = (world * Point::new(50.0, 50.0), world * Point::ZERO);
let from = corner + (corner - c) * 0.15;
let a = (from - c).atan2() - turn.to_radians();
let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0;
drag(&mut s, from, to, m);
rotation(&s, r)
}
#[test]
fn shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start() {
// Measured in Figma: a layer at 7° (or -4.27°) Shift-rotated by its corner ends at 15°, so the
// steps are absolute multiples of 15°, not 15° steps from where the drag began.
let got = rotate_drag_from(7.0, 37.0, SHIFT);
assert!(near(got, 45.0), "7° turned by 37° is 44°, nearest multiple of 15 is 45°: {got}");
// Without Shift the turn is free.
let got = rotate_drag_from(7.0, 37.0, NONE);
assert!(near(got, 44.0), "a free turn: {got}");
// A small turn: 7° + 3° = 10°, nearest multiple of 15 is 15° (not 7° or 10°).
let got = rotate_drag_from(7.0, 3.0, SHIFT);
assert!(near(got, 15.0), "10° rounds to 15°: {got}");
let got = rotate_drag_from(-4.27, 2.0, SHIFT);
assert!(near(got, 0.0), "-2.27° rounds to 0°: {got}");
}
/// Rotates a 120 × 80 layer inside a frame turned `frame_rot`, the layer at `start` on the page,
/// by `turn` from its corner, and returns its rotation on the page.
fn rotate_drag_in_frame(frame_rot: f64, start: f64, turn: f64, m: Mods) -> f64 {
let mut s = session();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400})));
set(&mut s, &[f], json!({"rotation": frame_rot}));
let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f));
set(&mut s, &[r], json!({"rotation": start - frame_rot}));
let page = |s: &Session| rotation_degrees(s.doc().unwrap().world_transform(r));
assert!(near(page(&s), start), "the layer starts at {start}° on the page: {}", page(&s));
ex(&mut s, "select.set", json!({"ids": [r]}));
let world = s.doc().unwrap().world_transform(r);
let (c, corner) = (world * Point::new(60.0, 40.0), world * Point::ZERO);
let from = corner + (corner - c) * 0.15;
let a = (from - c).atan2() - turn.to_radians();
let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0;
drag(&mut s, from, to, m);
page(&s)
}
#[test]
fn shift_rotating_inside_a_rotated_frame_lands_the_page_rotation_on_multiples_of_15() {
// Measured in Figma (2026-11-10): the rotation field of a layer inside a frame turned 10° shows
// its rotation on the page, and Shift lands that on multiples of 15°: from 7°, turns of 40°,
// 6° and -10° end at 45°, 15° and 0°; without Shift 6° ends at 13°. Groups behave the same.
for (turn, want) in [(40.0, 45.0), (6.0, 15.0), (-10.0, 0.0)] {
let got = rotate_drag_in_frame(10.0, 7.0, turn, SHIFT);
assert!(near(got, want), "7° on the page turned by {turn}° with Shift: {got}, want {want}");
}
let got = rotate_drag_in_frame(10.0, 7.0, 6.0, NONE);
assert!(near(got, 13.0), "a free turn: {got}");
}
#[test]
fn groups_scale_children_keeping_their_rotation_and_flip() {
let mut s = session();
+15
View File
@@ -811,6 +811,21 @@ fn generated_documents_round_trip() {
}
}
#[test]
fn a_boolean_groups_corner_radius_round_trips() {
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
let g = doc.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
doc.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap();
doc.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap();
doc.fit_to_children(g).unwrap();
doc.get_mut(g).unwrap().corners = Corners { radii: [20.0; 4], smoothing: 0.6 };
let (back, _) = round_trip(&doc, "boolean radius");
let n = back.nodes().find(|n| matches!(n.kind, NodeKind::BooleanOp { .. })).unwrap();
assert!(n.corners.radii.iter().all(|r| (r - 20.0).abs() < 1e-4), "{:?}", n.corners);
assert!((n.corners.smoothing - 0.6).abs() < 1e-4, "{:?}", n.corners);
}
#[test]
fn round_trip_keeps_the_details() {
let doc = generate(3);
+4
View File
@@ -290,6 +290,8 @@ fn vectors_ellipses_stars_and_booleans() {
counted["pointCount"] = json!(6);
let mut boolean = base("Bool", "BOOLEAN_OPERATION");
boolean["booleanOperation"] = json!("SUBTRACT");
boolean["cornerRadius"] = json!(12);
boolean["cornerSmoothing"] = json!(0.6);
boolean["children"] =
json!([base("A", "RECTANGLE"), {"id": "B", "name": "B", "type": "ELLIPSE", "relativeTransform": at(5.0, 5.0), "size": {"x": 10, "y": 10}}]);
let (doc, warnings) = one(json!([tri, wire, empty, arc, star, plain_star, counted, boolean]));
@@ -311,6 +313,8 @@ fn vectors_ellipses_stars_and_booleans() {
assert!(matches!(b.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }));
assert_eq!(b.children.len(), 2);
assert!(close(b.width, 15.0), "booleans wrap their children");
assert_eq!(b.corners.radii, [12.0; 4], "a boolean group keeps its corner radius");
assert!(close(b.corners.smoothing, 0.6));
}
#[test]
+6
View File
@@ -4,6 +4,8 @@
//! 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, variable width profiles ([`width`]) and brushes ([`brush`]).
//! - [`round_corners`]: a corner radius (and smoothing) on every corner still sharp in a closed path,
//! as a boolean group draws its result.
//!
//! Every sweep is capped ([`MAX_SEGMENTS`], [`MAX_CROSSINGS`], and [`MAX_FOLD_SEGMENTS`] over a
//! fold): past the caps an operation is refused with [`PathOpsError::TooComplex`].
@@ -24,6 +26,9 @@ mod dot_dash_tests;
#[cfg(test)]
mod ops_tests;
mod points;
mod round;
#[cfg(test)]
mod round_tests;
#[cfg(test)]
mod rounded_side_strokes_tests;
#[cfg(test)]
@@ -36,6 +41,7 @@ use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Vect
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2};
pub use budget::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, complexity};
pub use round::round_corners;
#[derive(Debug, PartialEq)]
pub enum PathOpsError {
+314
View File
@@ -0,0 +1,314 @@
//! A corner radius on a closed path: every vertex that is still sharp rounds with a circular arc,
//! convex and concave alike, the way a boolean group draws its result.
//!
//! What a sweep hands back is noisy: straight edges split at collinear points, pieces a hair long,
//! one real corner cut into two half turns. So each subpath is cleaned first ([`clean`]), then each
//! vertex is judged by the turn it makes, and a corner that does not fit shrinks to what the edges
//! beside it can hold ([`Ctx::corner`]).
use figcraft_geom::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Shape, Vec2};
use std::f64::consts::PI;
/// The smallest turn (radians) that counts as a corner. The sweep moves end points by up to its
/// own 1e-6 accuracy, which can tilt a short piece by more than 1e-6 rad.
const SHARP_TURN: f64 = 1e-3;
/// Accuracy of arc length measurements.
const ACC: f64 = 1e-6;
#[derive(Clone, Copy)]
enum Seg {
Line(Point, Point),
Cubic(CubicBez),
}
impl Seg {
fn start(&self) -> Point {
match self {
Self::Line(a, _) => *a,
Self::Cubic(c) => c.p0,
}
}
fn end(&self) -> Point {
match self {
Self::Line(_, b) => *b,
Self::Cubic(c) => c.p3,
}
}
fn set_start(&mut self, p: Point) {
match self {
Self::Line(a, _) => *a = p,
Self::Cubic(c) => c.p0 = p,
}
}
fn is_line(&self) -> bool {
matches!(self, Self::Line(..))
}
/// As a cubic (a line gets its handles at the thirds).
fn cubic(&self) -> CubicBez {
match self {
Self::Line(a, b) => CubicBez::new(*a, a.lerp(*b, 1.0 / 3.0), a.lerp(*b, 2.0 / 3.0), *b),
Self::Cubic(c) => *c,
}
}
fn len(&self) -> f64 {
match self {
Self::Line(a, b) => (*b - *a).hypot(),
Self::Cubic(c) => c.arclen(ACC),
}
}
/// The length of the control polygon: cheap, and zero only for a point.
fn span(&self) -> f64 {
let c = self.cubic();
(c.p1 - c.p0).hypot() + (c.p2 - c.p1).hypot() + (c.p3 - c.p2).hypot()
}
/// The cubic leaving the start of the segment, or (`from_start` false) leaving its end.
fn outward(&self, from_start: bool) -> CubicBez {
let c = self.cubic();
if from_start { c } else { CubicBez::new(c.p3, c.p2, c.p1, c.p0) }
}
}
/// Unit direction in which `c` leaves its start (skipping coincident handles).
fn start_dir(c: &CubicBez) -> Option<Vec2> {
[c.p1, c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot())
}
/// The point `t` along `c` from its start, the unit direction back toward the start there, and the
/// parameter reached.
fn trim(c: &CubicBez, t: f64, line: bool, len: f64) -> Option<(Point, Vec2, f64)> {
if line {
if !len.is_finite() || len <= 0.0 {
return None;
}
let u = (c.p3 - c.p0) / len;
return Some((c.p0 + u * t, -u, t / len));
}
let k = c.inv_arclen(t, ACC).clamp(0.0, 1.0);
let d = c.deriv().eval(k).to_vec2();
let back = if d.hypot() > 1e-9 { -d / d.hypot() } else { -start_dir(c)? };
Some((c.eval(k), back, k))
}
/// One rounded corner: the curves from the cut on the incoming segment to the cut on the outgoing
/// one, and where those cuts sit on their segments (each segment's own parameter).
struct Corner {
curves: Vec<CubicBez>,
t_in: f64,
t_out: f64,
}
/// `path` with every corner still sharp rounded by `radius` (and corner `smoothing`, 0..=1, between
/// straight edges). A corner shrinks to what its edges can hold: two corners wanting more than
/// half an edge share it half and half, a corner whose far end is smooth may use all of it. A
/// closed subpath with no sharp corner, an open one, and a path with a bad radius or coordinates
/// come back as they are.
pub fn round_corners(path: &BezPath, radius: f64, smoothing: f64) -> BezPath {
if !(radius.is_finite() && radius > 0.0) || path.elements().is_empty() || !crate::finite(path) {
return path.clone();
}
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
let mut out = BezPath::new();
for sub in figcraft_geom::split_subpaths(path) {
// Each contour's own size sets its cleanup tolerance, so a large contour can't weld away
// the real short edges of a small island or hole beside it.
let bb = sub.bounding_box();
let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9);
let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath));
match closed.then(|| round_subpath(&sub, radius, smoothing, weld)).flatten() {
Some(p) => out.extend(p.elements().iter().copied()),
None => out.extend(sub.elements().iter().copied()),
}
}
out
}
/// The segments of a closed subpath without the sweep's noise: pieces shorter than `weld` dropped,
/// each segment starting where the one before ends, and straight runs merged into one line.
fn clean(sub: &BezPath, weld: f64) -> Vec<Seg> {
let mut segs: Vec<Seg> = sub
.segments()
.map(|s| match s {
PathSeg::Line(l) => Seg::Line(l.p0, l.p1),
other => Seg::Cubic(other.to_cubic()),
})
.filter(|s| s.span() >= weld)
.collect();
let n = segs.len();
for i in 0..n {
let prev_end = segs.get((i + n - 1) % n).map(Seg::end);
if let (Some(p), Some(s)) = (prev_end, segs.get_mut(i)) {
s.set_start(p);
}
}
merge_runs(segs, weld)
}
/// Two lines A-B and B-C become A-C when B lies within `weld` of that line and between A and C
/// (a distance test, so a long edge with a real slight kink is not straightened). Cyclic.
fn merge_runs(segs: Vec<Seg>, weld: f64) -> Vec<Seg> {
let mergeable = |a: &Seg, b: &Seg| match (a, b) {
(Seg::Line(p, q), Seg::Line(_, r)) => {
let (d, e) = (*r - *p, *q - *p);
let l = d.hypot();
l > 1e-12 && (d.cross(e) / l).abs() <= weld && e.dot(*r - *q) > 0.0
}
_ => false,
};
let mut merged: Vec<Seg> = Vec::with_capacity(segs.len());
for s in segs {
match merged.last_mut() {
Some(last) if mergeable(last, &s) => *last = Seg::Line(last.start(), s.end()),
_ => merged.push(s),
}
}
while merged.len() > 2 {
let (Some(first), Some(last)) = (merged.first().copied(), merged.last().copied()) else { break };
if !mergeable(&last, &first) {
break;
}
if let Some(f) = merged.first_mut() {
*f = Seg::Line(last.start(), first.end());
}
merged.pop();
}
merged
}
/// Everything the corner of one vertex needs. Vertex `i` is where segment `i - 1` ends and
/// segment `i` starts.
struct Ctx<'a> {
segs: &'a [Seg],
/// Segment lengths, measured only for segments with a corner at one end.
lens: &'a [Option<f64>],
/// Per vertex: the turn between its segments and the length it wants along each of them.
wants: &'a [Option<(f64, f64)>],
radius: f64,
smoothing: f64,
weld: f64,
}
/// The angle between the segments at each vertex (pi is straight on, 0 folded back) and the
/// length a corner there wants along each; `None` where the vertex is not a corner.
fn wants(segs: &[Seg], radius: f64, smoothing: f64) -> Vec<Option<(f64, f64)>> {
let n = segs.len();
(0..n)
.map(|i| {
let (a, b) = (segs.get((i + n - 1) % n)?, segs.get(i)?);
let (u1, u2) = (start_dir(&a.outward(false))?, start_dir(&b.outward(true))?);
let angle = u1.dot(u2).clamp(-1.0, 1.0).acos();
if angle < 1e-6 || PI - angle < SHARP_TURN {
return None;
}
let t = radius / (angle / 2.0).tan();
let want = if a.is_line() && b.is_line() { (1.0 + smoothing) * t } else { t };
want.is_finite().then_some((angle, want))
})
.collect()
}
impl Ctx<'_> {
fn want(&self, i: usize) -> f64 {
self.wants.get(i % self.segs.len().max(1)).copied().flatten().map_or(0.0, |(_, w)| w)
}
/// What a corner may use of a segment of `len`: half of it, or what the corner at its other
/// end leaves, whichever is more.
fn room(len: f64, other: f64) -> f64 {
(len / 2.0).max(len - other)
}
fn corner(&self, i: usize) -> Option<Corner> {
let n = self.segs.len();
let (angle, _) = self.wants.get(i).copied().flatten()?;
let prev = (i + n - 1) % n;
let (a, b) = (self.segs.get(prev)?, self.segs.get(i)?);
let (la, lb) = (self.lens.get(prev).copied().flatten()?, self.lens.get(i).copied().flatten()?);
let room = Self::room(la, self.want(prev)).min(Self::room(lb, self.want(i + 1)));
if !room.is_finite() || room <= self.weld {
return None;
}
let (c1, c2) = (a.outward(false), b.outward(true));
if a.is_line() && b.is_line() {
self.between_lines(&c1, &c2, room, (la, lb))
} else {
self.beside_curve((&c1, a.is_line(), la), (&c2, b.is_line(), lb), room, angle)
}
}
fn between_lines(&self, c1: &CubicBez, c2: &CubicBez, room: f64, (la, lb): (f64, f64)) -> Option<Corner> {
let (u1, u2) = (start_dir(c1)?, start_dir(c2)?);
let curves = figcraft_geom::smoothed_corner(c1.p0, u1, u2, room, self.radius, self.smoothing)?;
let (first, last) = (curves.first()?, curves.last()?);
let (t_in, t_out) = (1.0 - (first.p0 - c1.p0).hypot() / la, (last.p3 - c1.p0).hypot() / lb);
(t_in.is_finite() && t_out.is_finite() && curves.iter().all(|c| [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite())))
.then_some(Corner { curves, t_in, t_out })
}
/// One cubic between the cuts on both sides, its handles along each side's direction at its cut.
fn beside_curve(
&self,
(c1, line1, la): (&CubicBez, bool, f64),
(c2, line2, lb): (&CubicBez, bool, f64),
room: f64,
angle: f64,
) -> Option<Corner> {
let t = (self.radius / (angle / 2.0).tan()).min(room);
let ((p, d1, k1), (q, d2, k2)) = (trim(c1, t, line1, la)?, trim(c2, t, line2, lb)?);
let turn = (-d1.dot(d2)).clamp(-1.0, 1.0).acos();
let chord = (q - p).hypot();
let k = if turn < 1e-9 { chord / 3.0 } else { 4.0 / 3.0 * (turn / 4.0).tan() * chord / (2.0 * (turn / 2.0).sin()) };
let ok = k.is_finite() && [p, q].iter().all(|x| x.x.is_finite() && x.y.is_finite());
ok.then(|| Corner { curves: vec![CubicBez::new(p, p + d1 * k, q + d2 * k, q)], t_in: 1.0 - k1, t_out: k2 })
}
}
/// The subpath with its corners rounded, or `None` when no vertex of it is a corner that rounds
/// (the caller keeps the subpath exactly as it came in).
fn round_subpath(sub: &BezPath, radius: f64, smoothing: f64, weld: f64) -> Option<BezPath> {
let segs = clean(sub, weld);
let n = segs.len();
if n < 2 {
return None;
}
let wants = wants(&segs, radius, smoothing);
let touches = |i: usize| wants.get(i).is_some_and(Option::is_some) || wants.get((i + 1) % n).is_some_and(Option::is_some);
let lens: Vec<Option<f64>> = segs.iter().enumerate().map(|(i, s)| touches(i).then(|| s.len())).collect();
let ctx = Ctx { segs: &segs, lens: &lens, wants: &wants, radius, smoothing, weld };
let corners: Vec<Option<Corner>> = (0..n).map(|i| ctx.corner(i)).collect();
corners.iter().any(Option::is_some).then(|| emit(&segs, &corners))?
}
/// Walk the segments: what is left of each between the corner at its start and the one at its
/// end, then the next corner's curves.
fn emit(segs: &[Seg], corners: &[Option<Corner>]) -> Option<BezPath> {
let n = segs.len();
let mut out = BezPath::new();
let first = segs.first()?;
out.move_to(corners.first().and_then(Option::as_ref).and_then(|c| c.curves.last()).map_or(first.start(), |c| c.p3));
for (i, seg) in segs.iter().enumerate() {
let next = corners.get((i + 1) % n).and_then(Option::as_ref);
let t0 = corners.get(i).and_then(Option::as_ref).map_or(0.0, |c| c.t_out);
let t1 = next.map_or(1.0, |c| c.t_in);
if t1 - t0 > 1e-9 {
match seg {
Seg::Line(a, b) => out.line_to(a.lerp(*b, t1)),
Seg::Cubic(c) => {
let q = c.subsegment(t0..t1);
out.curve_to(q.p1, q.p2, q.p3);
}
}
}
for c in next.iter().flat_map(|c| c.curves.iter()) {
out.curve_to(c.p1, c.p2, c.p3);
}
}
out.close_path();
crate::finite(&out).then_some(out)
}
+256
View File
@@ -0,0 +1,256 @@
//! `round_corners`: every sharp corner of a closed path, convex or concave, rounds with a circular
//! arc; smooth joins and straight-run leftovers of the sweep do not count; a radius larger than the
//! edges allow shrinks to fit.
use figcraft_doc::BoolOp;
use figcraft_geom::kurbo::{Affine, BezPath, PathEl, PathSeg, Point, Shape, Vec2};
use figcraft_geom::{ellipse, rounded_rect, split_subpaths};
use crate::{boolean, boolean_step, round_corners};
fn rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
kurbo_rect(x, y, w, h).to_path(0.1)
}
fn kurbo_rect(x: f64, y: f64, w: f64, h: f64) -> figcraft_geom::Rect {
figcraft_geom::Rect::new(x, y, x + w, y + h)
}
fn l_shape() -> BezPath {
boolean(&[rect(0.0, 0.0, 200.0, 100.0), rect(150.0, 0.0, 100.0, 200.0)], BoolOp::Union).unwrap()
}
/// A (200 by 100, radius 10) with a 100 square cut out of its top edge: the notch of the measurement.
fn notch() -> BezPath {
let a = rounded_rect(200.0, 100.0, [10.0; 4], 0.0);
boolean_step(&a, &rect(50.0, -50.0, 100.0, 100.0), BoolOp::Subtract).unwrap()
}
/// Every `CurveTo` as `[from, c1, c2, to]`.
fn arcs(path: &BezPath) -> Vec<[Point; 4]> {
let mut cur = Point::ZERO;
let mut out = Vec::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => cur = p,
PathEl::CurveTo(a, b, c) => {
out.push([cur, a, b, c]);
cur = c;
}
_ => {}
}
}
out
}
fn near(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-3
}
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
/// Some curve runs through exactly these four points, in either direction.
fn has_arc(path: &BezPath, want: [(f64, f64); 4]) -> bool {
let w = want.map(|(x, y)| pt(x, y));
let rev = [w[3], w[2], w[1], w[0]];
arcs(path).iter().any(|a| a.iter().zip(w).all(|(p, q)| near(*p, q)) || a.iter().zip(rev).all(|(p, q)| near(*p, q)))
}
/// Some curve joins these two end points, in either direction.
fn has_arc_between(path: &BezPath, from: (f64, f64), to: (f64, f64)) -> bool {
let (f, t) = (pt(from.0, from.1), pt(to.0, to.1));
arcs(path).iter().any(|a| (near(a[0], f) && near(a[3], t)) || (near(a[0], t) && near(a[3], f)))
}
fn passes_through(path: &BezPath, p: Point) -> bool {
path.elements().iter().any(|el| match el {
PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => near(*q, p),
_ => false,
})
}
fn unit(v: Vec2) -> Option<Vec2> {
(v.hypot() > 1e-9).then(|| v / v.hypot())
}
fn start_dir(s: PathSeg) -> Option<Vec2> {
let c = s.to_cubic();
[c.p1, c.p2, c.p3].into_iter().find_map(|p| unit(p - c.p0))
}
fn end_dir(s: PathSeg) -> Option<Vec2> {
let c = s.to_cubic();
[c.p2, c.p1, c.p0].into_iter().find_map(|p| unit(c.p3 - p))
}
/// The largest direction change at any vertex of a closed subpath, in radians.
fn sharpest_turn(path: &BezPath) -> f64 {
let mut worst: f64 = 0.0;
for sub in split_subpaths(path) {
let segs: Vec<PathSeg> = sub.segments().collect();
for (i, s) in segs.iter().enumerate() {
let next = segs[(i + 1) % segs.len()];
if let (Some(a), Some(b)) = (end_dir(*s), start_dir(next)) {
worst = worst.max(a.dot(b).clamp(-1.0, 1.0).acos());
}
}
}
worst
}
fn area(p: &BezPath) -> f64 {
p.area().abs()
}
fn all_finite(p: &BezPath) -> bool {
crate::finite(p)
}
#[test]
fn every_sharp_corner_of_the_result_rounds_convex_and_concave() {
let r = round_corners(&l_shape(), 20.0, 0.0);
assert_eq!(arcs(&r).len(), 6);
assert!(has_arc(&r, [(230.0, 0.0), (241.046, 0.0), (250.0, 8.95431), (250.0, 20.0)]));
assert!(has_arc(&r, [(150.0, 120.0), (150.0, 108.954), (141.046, 100.0), (130.0, 100.0)]));
assert!((area(&r) - (35000.0 - 4.0 * 85.84)).abs() < 0.5, "{}", area(&r));
assert_eq!(r.winding(pt(1.0, 1.0)), 0);
assert_ne!(r.winding(pt(148.0, 102.0)), 0);
let bb = r.bounding_box();
assert!((bb.width() - 250.0).abs() < 1e-6 && (bb.height() - 200.0).abs() < 1e-6);
}
#[test]
fn a_childs_rounded_corners_are_kept_and_do_not_stack() {
let r = round_corners(&notch(), 20.0, 0.0);
assert_eq!(arcs(&r).len(), 8);
assert!(has_arc(&r, [(30.0, 0.0), (41.0457, 0.0), (50.0, 8.954), (50.0, 20.0)]));
assert!(has_arc(&r, [(50.0, 30.0), (50.0, 41.0457), (58.954, 50.0), (70.0, 50.0)]));
assert!(has_arc_between(&r, (130.0, 50.0), (150.0, 30.0)));
assert!(has_arc_between(&r, (150.0, 20.0), (170.0, 0.0)));
assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)]));
}
#[test]
fn a_radius_larger_than_the_edges_clamps_to_what_they_hold() {
let r = round_corners(&notch(), 500.0, 0.0);
assert!(has_arc(&r, [(25.0, 0.0), (38.8071, 0.0), (50.0, 11.1929), (50.0, 25.0)]));
assert!(has_arc(&r, [(50.0, 25.0), (50.0, 38.807), (61.193, 50.0), (75.0, 50.0)]));
assert!(has_arc_between(&r, (125.0, 50.0), (150.0, 25.0)));
assert!(has_arc_between(&r, (150.0, 25.0), (175.0, 0.0)));
assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)]));
assert!(all_finite(&r));
let l = round_corners(&l_shape(), 500.0, 0.0);
assert!(has_arc_between(&l, (0.0, 50.0), (50.0, 0.0)));
assert!(has_arc_between(&l, (150.0, 0.0), (250.0, 100.0)));
assert!((area(&round_corners(&l_shape(), 1e300, 0.0)) - area(&l)).abs() < 1e-6);
}
#[test]
fn nothing_sharp_nothing_changes() {
let round = rounded_rect(100.0, 60.0, [20.0; 4], 0.0);
let oval = ellipse(100.0, 60.0, 0.0, std::f64::consts::TAU, 0.0);
assert_eq!(round_corners(&round, 20.0, 0.0), round);
assert_eq!(round_corners(&oval, 20.0, 0.0), oval);
let l = l_shape();
for radius in [0.0, -5.0, f64::NAN, f64::INFINITY] {
assert_eq!(round_corners(&l, radius, 0.0), l);
}
let mut open = BezPath::new();
open.move_to((0.0, 0.0));
open.line_to((50.0, 0.0));
open.line_to((50.0, 50.0));
assert_eq!(round_corners(&open, 10.0, 0.0), open);
}
#[test]
fn points_the_sweep_leaves_on_an_edge_are_not_corners() {
let mut p = BezPath::new();
for (i, q) in [(0.0, 0.0), (20.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 60.0), (100.0 + 1e-11, 60.0), (100.0, 100.0), (0.0, 100.0)]
.into_iter()
.enumerate()
{
if i == 0 {
p.move_to(q);
} else {
p.line_to(q);
}
}
p.close_path();
let r = round_corners(&p, 500.0, 0.0);
assert_eq!(arcs(&r).len(), 4);
assert!(has_arc_between(&r, (0.0, 50.0), (50.0, 0.0)));
}
fn two_circles(op: BoolOp) -> BezPath {
let c = ellipse(100.0, 100.0, 0.0, std::f64::consts::TAU, 0.0);
boolean(&[c.clone(), Affine::translate((70.0, 0.0)) * c], op).unwrap()
}
#[test]
fn corners_beside_curves_round_too() {
for op in [BoolOp::Union, BoolOp::Intersect] {
let sharp = two_circles(op);
assert!(sharpest_turn(&sharp) > 1.0);
let r = round_corners(&sharp, 10.0, 0.0);
assert!(sharpest_turn(&r) < 1e-3, "{}", sharpest_turn(&r));
assert!(all_finite(&r));
match op {
BoolOp::Union => assert!(area(&r) > area(&sharp)),
_ => assert!(area(&r) < area(&sharp)),
}
}
}
#[test]
fn smoothing_draws_the_polygon_profile_between_straight_edges() {
let l = l_shape();
let plain = round_corners(&l, 20.0, 0.0);
let smooth = round_corners(&l, 20.0, 0.6);
assert!(passes_through(&smooth, pt(0.0, 32.0)) && passes_through(&smooth, pt(32.0, 0.0)));
assert!((area(&smooth) - area(&plain)).abs() > 1.0);
let circles = two_circles(BoolOp::Union);
assert_eq!(round_corners(&circles, 10.0, 0.6), round_corners(&circles, 10.0, 0.0));
}
#[test]
fn junk_never_panics() {
let mut bad = BezPath::new();
bad.move_to((f64::NAN, 0.0));
bad.line_to((10.0, f64::INFINITY));
bad.close_path();
assert_eq!(round_corners(&bad, 5.0, 0.0).elements().len(), 3);
assert!(round_corners(&BezPath::new(), 5.0, 0.0).elements().is_empty());
let mut one = BezPath::new();
one.move_to((0.0, 0.0));
one.line_to((10.0, 0.0));
one.close_path();
assert!(all_finite(&round_corners(&one, 5.0, 0.5)));
assert!(all_finite(&round_corners(&rect(0.0, 0.0, 1.0, 0.5), 0.1, 0.0)));
let mut zig = BezPath::new();
zig.move_to((0.0, 0.0));
for i in 1..40_000 {
zig.line_to((f64::from(i) * 0.5, f64::from(i % 2)));
}
zig.close_path();
assert!(all_finite(&round_corners(&zig, 2.0, 0.0)));
}
/// The cleanup tolerance is each contour's own: a contour a million units wide doesn't weld away
/// the half-unit notch of a separate small contour in the same path.
#[test]
fn a_large_contour_does_not_weld_a_small_ones_short_edges() {
let mut p = rect(0.0, 0.0, 1e6, 1e6);
let x = 2e6;
for (i, (dx, y)) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.5, 10.0), (5.5, 9.5), (5.0, 9.5), (5.0, 10.0), (0.0, 10.0)].into_iter().enumerate()
{
if i == 0 { p.move_to((x + dx, y)) } else { p.line_to((x + dx, y)) }
}
p.close_path();
let out = round_corners(&p, 0.001, 0.0);
let reaches = |q: Point| {
out.elements().iter().any(|el| matches!(el, PathEl::MoveTo(e) | PathEl::LineTo(e) | PathEl::CurveTo(_, _, e) if (*e - q).hypot() < 0.01))
};
assert!(reaches(pt(x + 5.0, 9.5)) && reaches(pt(x + 5.5, 9.5)), "the notch's floor is kept");
}
+29
View File
@@ -7,6 +7,11 @@
//! edited one are never recombined, and groups whose operands didn't change aren't recomputed at
//! all. The caches drop their least recently used half when full, so a long drag doesn't evict
//! every other group's result.
//!
//! The group's own corner radius is applied to the finished fold ([`result_cached`]) and cached in a
//! separate, smaller map keyed by the folded path plus the radius and smoothing: the fold's caches
//! are keyed by the operands alone, so scrubbing the radius never redoes the fold or evicts its
//! prefixes, and a rounded path is never stored where a fold would find it.
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
@@ -109,6 +114,30 @@ pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> Option<BezPath> {
}
}
fn rounded() -> &'static Mutex<PathCache> {
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PathCache::new(512)))
}
/// A boolean group's result as it is drawn: the fold of `operands` ([`fold_cached`]), then the
/// group's corner `radius` and `smoothing` on every corner still sharp.
pub fn result_cached(operands: &[(BezPath, BoolOp)], radius: f64, smoothing: f64) -> Option<BezPath> {
let folded = fold_cached(operands)?;
if !(radius.is_finite() && radius > 0.0) {
return Some(folded);
}
let mut h = DefaultHasher::new();
hash_path(&folded, &mut h);
(radius.to_bits(), smoothing.to_bits(), 0x52u8).hash(&mut h);
let key = h.finish();
if let Some(p) = locked(rounded()).get(key) {
return Some((*p).clone());
}
let out = figcraft_pathops::round_corners(&folded, radius, smoothing);
locked(rounded()).insert(key, Arc::new(out.clone()));
Some(out)
}
/// `keys[i]`: the cache key of the fold of operands `0..=i`.
fn fold_keys(operands: &[(BezPath, BoolOp)]) -> Vec<u64> {
let mut h = DefaultHasher::new();
+3 -3
View File
@@ -7,12 +7,12 @@ use figcraft_color::{Color, Paint};
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile};
use figcraft_geom::kurbo::{BezPath, Shape};
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
pub(crate) fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
let i = ((y * p.width() + x) * 4) as usize;
p.data().get(i).is_some_and(|r| *r < 40)
}
fn render(d: &Document, p: NodeId) -> Pixmap {
pub(crate) fn render(d: &Document, p: NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap()
}
@@ -28,7 +28,7 @@ fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec<NodeId
(d, p, b, kids)
}
fn outline(d: &Document, id: NodeId) -> BezPath {
pub(crate) fn outline(d: &Document, id: NodeId) -> BezPath {
node_outline(d, d.node(id).unwrap()).unwrap().0
}
+60
View File
@@ -0,0 +1,60 @@
//! A boolean group's corner radius: it rounds the result the canvas draws, and keys a cache of its
//! own so the fold underneath is not redone.
use crate::boolean::boolean_steps;
use crate::boolean_cache_tests::{dark, outline, render};
use figcraft_color::{Color, Paint};
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind};
use figcraft_geom::kurbo::Shape;
/// The L of the measurement: a union of 200 by 100 and 100 by 200 rectangles, on a page of its own.
fn l_group() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let g = d.create(p, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
d.get_mut(g).unwrap().fills = vec![Paint::solid(Color::BLACK)];
d.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap();
let b = d.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap();
d.fit_to_children(g).unwrap();
(d, p, g, b)
}
fn set_radius(d: &mut Document, g: NodeId, r: f64) {
d.get_mut(g).unwrap().corners.radii = [r; 4];
}
fn area(d: &Document, g: NodeId) -> f64 {
outline(d, g).area().abs()
}
#[test]
fn a_boolean_groups_radius_rounds_what_the_canvas_draws() {
let (mut d, p, g, _) = l_group();
let pix = render(&d, p);
assert!(dark(&pix, 1, 1) && !dark(&pix, 148, 102));
set_radius(&mut d, g, 20.0);
let pix = render(&d, p);
assert!(!dark(&pix, 1, 1), "the corner is cut off");
assert!(dark(&pix, 148, 102), "the concave corner is filled in");
assert!(dark(&pix, 100, 50));
assert!((area(&d, g) - 34656.6).abs() < 1.0, "{}", area(&d, g));
}
#[test]
fn the_radius_keys_the_result_and_the_fold_is_not_redone() {
let (mut d, _, g, b) = l_group();
let sharp = outline(&d, g);
set_radius(&mut d, g, 20.0);
let _ = outline(&d, g);
let before = boolean_steps();
set_radius(&mut d, g, 40.0);
// Four net corners of 343.36 each, plus the cubic arcs' own slight excess over a circle.
assert!((area(&d, g) - 33626.6).abs() < 2.0, "{}", area(&d, g));
assert_eq!(boolean_steps(), before, "a new radius reuses the fold");
set_radius(&mut d, g, 0.0);
assert_eq!(outline(&d, g), sharp, "radius 0 draws the fold itself");
set_radius(&mut d, g, 20.0);
let rounded = area(&d, g);
d.get_mut(b).unwrap().transform = figcraft_geom::Affine::translate((10.0, 0.0)) * d.node(b).unwrap().transform;
assert!((area(&d, g) - rounded).abs() > 1.0, "moving an operand does not draw a stale rounded path");
}
+3 -2
View File
@@ -347,8 +347,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
Some((p, c.bool_op.unwrap_or(*op)))
})
.collect();
// Past the path operations' caps the group has no outline: it draws without its result.
crate::boolean::fold_cached(&operands).map(|p| (p, FillRule::EvenOdd))
// Past the path operations' caps the group has no outline: it draws without its result. The
// group's corner radius rounds what is still sharp in the fold.
crate::boolean::result_cached(&operands, n.corners.radii[0], n.corners.smoothing).map(|p| (p, FillRule::EvenOdd))
}
NodeKind::Text(_) => {
let (l, dy) = figcraft_text::layout_node(n)?;
+2
View File
@@ -550,6 +550,8 @@ mod boolean_budget_tests;
#[cfg(test)]
mod boolean_cache_tests;
#[cfg(test)]
mod boolean_radius_tests;
#[cfg(test)]
mod bounds_memo_tests;
#[cfg(test)]
mod dots_tests;
+1
View File
@@ -886,6 +886,7 @@ name = "English"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}"
"Remove these results from the file" = "Remove these results from the file"
"Scroll wheel zoom" = "Scroll wheel zoom"
"Invert zoom direction" = "Invert zoom direction"
"Shared by your team on {server}" = "Shared by your team on {server}"
"Slot name" = "Slot name"
"Mar" = "Mar"
+2
View File
@@ -36,6 +36,7 @@ status = "draft — needs native review"
"Couldn't download the variables" = "No se pudieron descargar las variables"
"Couldn't download {name}" = "No se pudo descargar {name}"
"Create from color" = "Crear a partir del color"
"Invert zoom direction" = "Invertir la dirección del zoom"
"New collection" = "Nueva colección"
"Delete results" = "Eliminar resultados"
"Pasted design layers" = "Capas de diseño pegadas"
@@ -2216,6 +2217,7 @@ status = "draft — needs native review"
"plugins.runLast" = "Ejecutar el último plugin"
"pref.autoSave" = "Guardar archivos automáticamente"
"pref.highlightHover" = "Resaltar capas al pasar el cursor"
"pref.invertZoom" = "Invertir la dirección del zoom"
"pref.keepTool" = "Mantener la herramienta tras usarla"
"pref.language" = "Idioma de la interfaz"
"pref.nudge" = "Cantidad de desplazamiento"
+24 -4
View File
@@ -1,7 +1,7 @@
//! The pointer on the canvas: each engine cursor ([`figcraft_engine::tools::Cursor`]) as one of
//! the system's cursors, or as a small glyph FigCraft draws at the pointer with the system's
//! hidden (a rotate cursor turned to its corner, the pen's states, the eyedropper, the comment
//! pin). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images.
//! pin, the crosshair of the shape and Text tools). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images.
use egui::{Color32, CursorIcon, Painter, Pos2, Shape, Stroke, pos2, vec2};
use figcraft_engine::tools::{Cursor, PenCursor};
@@ -17,6 +17,9 @@ pub enum Glyph {
Pen(PenCursor),
Eyedropper,
Comment,
/// A plus open at the centre, for the shape and Text tools (the system's follows the
/// system's pointer colour, and a white one is lost on a white frame).
Crosshair,
}
/// What the pointer shows.
@@ -46,7 +49,7 @@ pub fn of(cursor: Cursor) -> Shown {
use Shown::{Drawn, System};
match cursor {
Cursor::Default => System(CursorIcon::Default),
Cursor::Crosshair => System(CursorIcon::Crosshair),
Cursor::Crosshair => Drawn(Glyph::Crosshair),
Cursor::Move => System(CursorIcon::Move),
Cursor::Resize(a) if a.is_finite() => System(resize_icon(a)),
Cursor::Resize(_) => System(CursorIcon::Default),
@@ -85,7 +88,7 @@ impl Shown {
/// A name for it (`ui.inspect`): the CSS cursor names where there is one ("default",
/// "nwse-resize", "copy"), and "rotate" (with its angle), "pen", "pen-close", "pen-add",
/// "pen-bend", "eyedropper" and "comment" for the drawn ones.
/// "pen-bend", "eyedropper", "comment" and "crosshair" for the drawn ones.
pub fn name(self) -> String {
match self {
Shown::System(i) => match i {
@@ -114,6 +117,7 @@ impl Shown {
Shown::Drawn(Glyph::Pen(PenCursor::Bend)) => "pen-bend".into(),
Shown::Drawn(Glyph::Eyedropper) => "eyedropper".into(),
Shown::Drawn(Glyph::Comment) => "comment".into(),
Shown::Drawn(Glyph::Crosshair) => "crosshair".into(),
}
}
@@ -127,7 +131,7 @@ impl Shown {
}
/// Dark lines over a light edge: readable on any page and artwork.
const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E);
pub(crate) const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E);
const EDGE: Color32 = Color32::WHITE;
/// A line through `pts` with the light edge under it.
@@ -167,6 +171,22 @@ pub fn paint(painter: &Painter, at: Pos2, glyph: Glyph) {
line(painter, &[tip - t * 4.5 + n * 3.5, tip + t * 1.0, tip - t * 4.5 - n * 3.5]);
}
}
Glyph::Crosshair => {
// A plus centred on the pointer, open at the centre so its pixel stays visible; the
// light edge goes under both arms of each line first, then the dark line on top.
let arms = [
(vec2(-8.0, 0.0), vec2(-2.0, 0.0)),
(vec2(2.0, 0.0), vec2(8.0, 0.0)),
(vec2(0.0, -8.0), vec2(0.0, -2.0)),
(vec2(0.0, 2.0), vec2(0.0, 8.0)),
];
for (a, b) in arms {
painter.add(Shape::line_segment([at + a, at + b], Stroke::new(3.0, EDGE)));
}
for (a, b) in arms {
painter.add(Shape::line_segment([at + a, at + b], Stroke::new(1.0, INK)));
}
}
Glyph::Pen(state) => {
// A cross open at the centre (the point it lands on stays visible), and what a click
// does at its lower right.
+10 -2
View File
@@ -81,10 +81,18 @@ fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) ->
if let Some(mouse) = latest {
app.canvas.wheel_is_mouse = mouse;
}
let invert = app.session.prefs.invert_zoom && !pinch;
if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch {
return (wheel, zoom);
return (wheel, if invert { invert_zoom_factor(zoom) } else { zoom });
}
swap_wheel(wheel, zoom, shift, speed)
let (scroll, zoom) = swap_wheel(wheel, zoom, shift, speed);
(scroll, if invert { invert_zoom_factor(zoom) } else { zoom })
}
/// The zoom factor for the opposite direction (Preferences ▸ Invert zoom direction): its
/// reciprocal. A factor that is zero, negative or not finite is left as it is.
pub(crate) fn invert_zoom_factor(zoom: f32) -> f32 {
if zoom.is_finite() && zoom > 0.0 { 1.0 / zoom } else { zoom }
}
/// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere
+66 -1
View File
@@ -6,7 +6,7 @@ use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, R
use figcraft_engine::Session;
use serde_json::json;
use super::input::{is_mouse_wheel, swap_wheel};
use super::input::{invert_zoom_factor, is_mouse_wheel, swap_wheel};
use crate::{FigApp, NoServices};
/// The view's zoom, cx and cy.
@@ -25,8 +25,13 @@ fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event {
impl Canvas {
fn new(scroll_wheel_zoom: bool) -> Self {
Self::with_invert(scroll_wheel_zoom, false)
}
fn with_invert(scroll_wheel_zoom: bool, invert_zoom: bool) -> Self {
let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 };
c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom}));
c.app.run("pref.invertZoom", json!({"on": invert_zoom}));
for _ in 0..3 {
c.frame(vec![], Modifiers::NONE);
}
@@ -126,3 +131,63 @@ fn the_swap_keeps_egui_rates() {
assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0));
assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0));
}
#[test]
fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() {
for mods in [Modifiers::COMMAND, Modifiers::CTRL] {
let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true));
let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods);
let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods);
assert!(b.0 > a.0 * 1.05, "{mods:?} off zooms in: {a:?} -> {b:?}");
assert!(d.0 < c.0 / 1.05, "{mods:?} on zooms out: {c:?} -> {d:?}");
assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "{mods:?} by the same factor");
}
}
#[test]
fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() {
let (mut off, mut on) = (Canvas::with_invert(true, false), Canvas::with_invert(true, true));
let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE);
let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE);
assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}");
assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}");
assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor");
}
/// Panning is never inverted: the scroll vector is the same with the preference on and off.
#[test]
fn invert_zoom_leaves_panning_unchanged() {
// (Scroll wheel zoom, modifiers): a plain wheel without it, Cmd/Ctrl+wheel with it, Shift+wheel.
for (swz, mods) in [(false, Modifiers::NONE), (true, Modifiers::COMMAND), (true, Modifiers::CTRL), (true, Modifiers::SHIFT)] {
let (mut off, mut on) = (Canvas::with_invert(swz, false), Canvas::with_invert(swz, true));
let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods);
let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods);
assert!((b.0 - a.0).abs() < 1e-9 && (d.0 - c.0).abs() < 1e-9, "{mods:?} no zoom");
assert!(b.1 != a.1 || b.2 != a.2, "{mods:?} off pans: {a:?} -> {b:?}");
assert!(
((b.1 - a.1) - (d.1 - c.1)).abs() < 1e-9 && ((b.2 - a.2) - (d.2 - c.2)).abs() < 1e-9,
"{mods:?} same pan: {a:?} -> {b:?} vs {c:?} -> {d:?}"
);
}
}
#[test]
fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() {
for (swz, invert, factor) in
[(false, false, 1.5), (false, true, 1.5), (true, false, 1.5), (true, true, 1.5), (true, true, 0.5), (false, true, 0.5)]
{
let mut c = Canvas::with_invert(swz, invert);
let (a, b, _) = c.gesture(Event::Zoom(factor), Modifiers::NONE);
assert!((b.0 / a.0 - f64::from(factor)).abs() < 1e-3, "scroll wheel zoom {swz}, invert {invert}, x{factor}: {a:?} -> {b:?}");
}
}
#[test]
fn the_inverted_factor_is_the_reciprocal_and_guards_bad_values() {
assert!((invert_zoom_factor(2.0) - 0.5).abs() < 1e-6);
assert_eq!(invert_zoom_factor(1.0), 1.0);
for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
let r = invert_zoom_factor(bad);
assert!(r == bad || (r.is_nan() && bad.is_nan()), "{bad} left alone");
}
}
+33 -16
View File
@@ -166,25 +166,42 @@ fn polygons_and_stars_take_corner_smoothing() {
for cmd in ["shape.polygon", "shape.star"] {
let mut s = Session::new();
let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap());
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut u = Ui::new(s);
// With no corners of their own, the button is named for what it opens.
let t = named_button(&mut u, "Corner smoothing");
click(&mut u, t);
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields");
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
let history = u.app.session.undo_depth().0;
click(&mut u, smoothing);
u.frame(vec![Event::Text("60".into())]);
enter(&mut u);
u.frame(vec![]);
let n = u.app.session.doc().unwrap().node(id).unwrap().clone();
assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing);
assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step");
takes_corner_smoothing(s, id, cmd);
}
}
/// A boolean group has one radius for its whole result (#2222): like a polygon, its button opens
/// corner smoothing, with no field per corner.
#[test]
fn a_boolean_group_opens_corner_smoothing_and_has_no_corner_fields() {
let mut s = Session::new();
let rect = |s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone();
let (a, b) = (rect(&mut s, 0), rect(&mut s, 60));
let g = NodeId(s.execute("bool.union", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap());
takes_corner_smoothing(s, g, "bool.union");
}
/// With `id` rounded at 8 and selected: the panel's button is named Corner smoothing and opens
/// the smoothing field with no field per corner, and typing 60 sets the smoothing in one undo step.
fn takes_corner_smoothing(mut s: Session, id: NodeId, what: &str) {
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut u = Ui::new(s);
// With no corners of their own, the button is named for what it opens.
let t = named_button(&mut u, "Corner smoothing");
click(&mut u, t);
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{what}: one radius, no corner fields");
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
let history = u.app.session.undo_depth().0;
click(&mut u, smoothing);
u.frame(vec![Event::Text("60".into())]);
enter(&mut u);
u.frame(vec![]);
let n = u.app.session.doc().unwrap().node(id).unwrap().clone();
assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{what}: {}", n.corners.smoothing);
assert_eq!(u.app.session.undo_depth().0, history + 1, "{what}: one undo step");
}
/// A closed vector shows the same Corner smoothing control, and its iOS button sets 60% in one
/// undo step; an open vector, which has no closed shape to smooth, shows no smoothing control.
#[test]
+43 -5
View File
@@ -22,7 +22,7 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() {
// (engine cursor, what shows, its name in `ui.inspect`)
let rows: Vec<(Cursor, Shown, &str)> = vec![
(Cursor::Default, System(CursorIcon::Default), "default"),
(Cursor::Crosshair, System(CursorIcon::Crosshair), "crosshair"),
(Cursor::Crosshair, Drawn(Glyph::Crosshair), "crosshair"),
(Cursor::Move, System(CursorIcon::Move), "move"),
(Cursor::Resize(0.0), System(CursorIcon::ResizeHorizontal), "ew-resize"),
(Cursor::Resize(90.0), System(CursorIcon::ResizeVertical), "ns-resize"),
@@ -59,6 +59,44 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() {
assert_eq!(of(Cursor::Rotate(f64::INFINITY)), Drawn(Glyph::Rotate(0.0)));
}
#[test]
fn the_crosshair_is_drawn_dark_with_a_white_edge_so_it_shows_on_white() {
use egui::epaint::Shape;
let shown = of(Cursor::Crosshair);
assert_eq!(shown, Shown::Drawn(Glyph::Crosshair));
// The system's pointer is hidden under it, whatever its colour.
assert_eq!(shown.icon(), CursorIcon::None);
let at = pos2(50.0, 40.0);
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let ctx = ui.ctx().clone();
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("crosshair_test"));
crate::cursors::paint(&ctx.layer_painter(layer), at, Glyph::Crosshair);
});
out.textures_delta.clear();
let mut strokes = Vec::new();
for clipped in &out.shapes {
if let Shape::LineSegment { points, stroke } = &clipped.shape {
strokes.push((points.to_vec(), stroke.color, stroke.width));
}
}
let dark: Vec<_> = strokes.iter().filter(|s| s.1 == crate::cursors::INK).collect();
let white: Vec<_> = strokes.iter().filter(|s| s.1 == egui::Color32::WHITE).collect();
assert!(!dark.is_empty() && !white.is_empty(), "{strokes:?}");
// The white edge is wider than the dark line and is painted first.
assert!(white.iter().all(|w| w.2 > dark[0].2), "{strokes:?}");
assert_eq!(strokes[0].1, egui::Color32::WHITE);
// The arms are centred on the pointer and leave its pixel clear.
for (pts, ..) in &dark {
for p in pts {
let d = (p.x - at.x).abs().max((p.y - at.y).abs());
assert!((2.0..=8.0).contains(&d), "{p:?}");
assert!((p.x - at.x).abs() < 0.01 || (p.y - at.y).abs() < 0.01, "{p:?}");
}
}
assert_eq!(dark.len(), 4);
}
#[test]
fn resize_arrows_follow_the_angle_in_eighths() {
// (degrees, the two-headed arrow): each covers 45° about its own direction, any turn.
@@ -134,10 +172,10 @@ fn the_window_asks_for_the_cursor_of_the_tool_under_the_pointer() {
("move", edge, &[], no, CursorIcon::ResizeHorizontal, "ew-resize"),
("move", on, &[], alt, CursorIcon::Copy, "copy"),
("hand", empty, &[], no, CursorIcon::Grab, "grab"),
("rectangle", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("frame", on, &[], no, CursorIcon::Crosshair, "crosshair"),
("slice", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("text", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("rectangle", empty, &[], no, CursorIcon::None, "crosshair"),
("frame", on, &[], no, CursorIcon::None, "crosshair"),
("slice", empty, &[], no, CursorIcon::None, "crosshair"),
("text", empty, &[], no, CursorIcon::None, "crosshair"),
("pen", empty, &[], no, CursorIcon::None, "pen"),
("comment", empty, &[], no, CursorIcon::None, "comment"),
("move", empty, &[Key::Space], no, CursorIcon::Grab, "grab"),
+1
View File
@@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
(tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"),
(tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"),
(tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"),
(tr("Invert zoom direction"), prefs.invert_zoom, "pref.invertZoom"),
(tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"),
] {
if crate::widgets::checkbox(ui, val, label) {
+36 -4
View File
@@ -189,7 +189,7 @@ fn setup() -> (Ui, NodeId, NodeId) {
/// The topmost field named `name` in the Design panel: its value centre and displayed text.
/// Layer opacity precedes the equally named opacity fields in paint rows.
fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
pub(crate) fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
@@ -234,7 +234,7 @@ fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) {
(b.x0, b.y0, b.width(), b.height())
}
fn undo_steps(u: &Ui) -> usize {
pub(crate) fn undo_steps(u: &Ui) -> usize {
u.app.session.ds().unwrap().history.undo.len()
}
@@ -252,7 +252,7 @@ fn click(u: &mut Ui, p: Pos2) {
}
/// Click into field `name`, type `text` over what it shows and press `end`.
fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
pub(crate) fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
let (p, _) = field(u, name);
click(u, p);
u.frame(vec![Event::Text(text.into())]);
@@ -261,7 +261,7 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
}
/// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go.
fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
pub(crate) fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
let from = label(u, name);
drag(u, from, dx, modifiers);
}
@@ -1670,3 +1670,35 @@ fn alt_held_over_a_field_scrubs_it() {
u.frame(vec![]);
assert_eq!(geom(&u, a).0, 65.0, "ten more");
}
#[test]
fn a_boolean_group_has_one_corner_radius_field_and_a_mixed_selection_reads_mixed() {
let (mut u, a, b) = setup();
let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
let r = id(u.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 0, "width": 50, "height": 50})).unwrap());
u.app.run("select.set", json!({"ids": [g]}));
assert_eq!(field(&mut u, "Corner radius").1, "0");
let steps = undo_steps(&u);
type_into(&mut u, "Corner radius", "20", Key::Enter);
let radii = |u: &Ui, n: NodeId| u.app.session.doc().unwrap().node(n).unwrap().corners.radii;
assert_eq!(radii(&u, g), [20.0; 4]);
assert_eq!(undo_steps(&u), steps + 1, "one undo step");
u.app.run("select.set", json!({"ids": [g, r]}));
assert_eq!(field(&mut u, "Corner radius").1, "Mixed");
type_into(&mut u, "Corner radius", "8", Key::Enter);
assert_eq!((radii(&u, g), radii(&u, r)), ([8.0; 4], [8.0; 4]));
}
/// A boolean group draws its first radius; per-corner values a script or a file left behind don't
/// make its field read Mixed, and a relative edit starts from the radius it draws.
#[test]
fn a_boolean_groups_field_shows_the_radius_it_draws() {
let (mut u, a, b) = setup();
let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
let corners = json!({"topRightRadius": 30, "bottomRightRadius": 30, "bottomLeftRadius": 30});
u.app.session.execute("node.set", &json!({"ids": [g], "props": corners})).unwrap();
u.app.run("select.set", json!({"ids": [g]}));
assert_eq!(field(&mut u, "Corner radius").1, "0", "the radius it draws");
type_into(&mut u, "Corner radius", "+10", Key::Enter);
assert_eq!(u.app.session.doc().unwrap().node(g).unwrap().corners.radii, [10.0; 4], "0 + 10, not 30 + 10");
}
+2
View File
@@ -292,6 +292,8 @@ mod vector_keys_tests;
#[cfg(test)]
mod vector_panel_tests;
#[cfg(test)]
mod vector_point_xy_tests;
#[cfg(test)]
mod vector_tail_ui_tests;
#[cfg(test)]
mod vote_history_ui_tests;
+13 -6
View File
@@ -52,11 +52,16 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round();
let op = common(doc, sel, op_of);
let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius());
// Only rectangles and frames round each corner on its own.
// Only rectangles and frames round each corner on its own; a boolean group has one radius for
// every sharp corner of its result.
let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners());
// For polygons, stars and closed vectors the same panel holds only the Corner smoothing row.
// For polygons, stars, boolean groups and closed vectors the same panel holds only the Corner
// smoothing row.
let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing());
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
// A layer with one radius (polygon, star, boolean group, vector) draws its first one, whatever
// the other three hold; a rectangle or frame shows a value only when its four corners agree.
let shown = |n: &Node| if n.kind.has_independent_corners() { n.corners.uniform() } else { n.corners.radii.first().copied() };
let r = common(doc, sel, |n| shown(n).map(|r| (r * 100.0).round() / 100.0));
widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius")));
row(ui, 32.0, |ui| {
let lead = Lead::Icon(Icon::Opacity, tr("Opacity"));
@@ -74,11 +79,13 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
"",
col,
);
// A layer with corners of its own starts from its largest.
let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max));
// A relative edit starts from the radius shown; a rectangle with differing corners
// starts from its largest.
let radius_of = |n: &Node| shown(n).unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max));
app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0)));
let open = ui.data(|d| d.get_temp::<bool>(Id::unique("indep_corners"))).unwrap_or(false);
// Polygons and stars have no corners of their own: the button opens only Corner smoothing.
// Polygons, stars and boolean groups have no corners of their own: the button opens only
// Corner smoothing.
let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") };
if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() {
ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open));
+28 -6
View File
@@ -12,7 +12,7 @@ use crate::FigApp;
use crate::i18n::tr;
use crate::icons::Icon;
use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row};
use crate::widgets::{self, FieldEdit, Lead};
use crate::widgets::{self, Edit, FieldEdit, Lead, Rel};
/// Run a point command from a field: typed values are one step, scrubs one step when released.
fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: impl Fn(f64) -> Value) {
@@ -31,6 +31,23 @@ fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: imp
}
}
/// A point position field's edit: a value (typed or scrubbed) sets every selected point's own
/// coordinate; a relative edit of a Mixed field (`+10`, `*2`) applies to each point's own.
fn point_edit(app: &mut FigApp, axis: &'static str, edit: Option<Edit>) {
match edit {
Some(Edit::All(e)) => point_field(app, "vector.edit.position", Some(e), |v| json!({ axis: v })),
Some(Edit::Each(rel)) => {
let op = match rel {
Rel::Add(n) => json!({"add": n}),
Rel::Mul(n) => json!({"mul": n}),
Rel::Div(n) => json!({"div": n}),
};
app.run("vector.edit.position", json!({ axis: op }));
}
None => {}
}
}
/// The Variable width tool's selected width point: where it is along the path and its width.
fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
widgets::section_header(ui, tr("Width point"), &[], false);
@@ -88,14 +105,19 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
return;
}
let col = widgets::col_width(ui.available_width());
let pos = vectoredit::selection_position(doc, ve);
let round = |v: f64| (v * 100.0).round() / 100.0;
let parent_pts = vectoredit::selected_parent_points(doc, ve);
// The value the selected points share (within 1e-6), or none: Mixed.
let shared = |axis: fn(&figcraft_geom::Point) -> f64| {
let first = parent_pts.first().map(axis)?;
parent_pts.iter().all(|p| (axis(p) - first).abs() < 1e-6).then(|| round(first))
};
widgets::sublabels(ui, tr("Position"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::unique("vpt_x"), Lead::Text("X"), pos.map(|p| round(p.x)), "", col, 1.0);
point_field(app, "vector.edit.position", e, |v| json!({"x": v}));
let e = widgets::number_field(ui, Id::unique("vpt_y"), Lead::Text("Y"), pos.map(|p| round(p.y)), "", col, 1.0);
point_field(app, "vector.edit.position", e, |v| json!({"y": v}));
let e = widgets::number_field_mixed(ui, Id::unique("vpt_x"), Lead::Text("X"), shared(|p| p.x), "", col, 1.0);
point_edit(app, "x", e);
let e = widgets::number_field_mixed(ui, Id::unique("vpt_y"), Lead::Text("Y"), shared(|p| p.y), "", col, 1.0);
point_edit(app, "y", e);
});
if let Some(b) = vectoredit::transform::point_box(doc, ve).filter(|b| b.points.len() > 1) {
widgets::sublabels(ui, tr("Dimensions"), None);
@@ -0,0 +1,79 @@
//! The vector edit panel's Point section: X and Y show the value the selected points share, or
//! "Mixed", and a typed value sets every selected point's own coordinate.
use egui::{Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::field_tests::{field, scrub, type_into, undo_steps};
use crate::layers_drag_tests::Ui;
fn setup() -> (Ui, NodeId) {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("vector.edit.begin", &json!({"id": id})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
(u, id)
}
fn xs(u: &Ui, id: NodeId) -> Vec<(f64, f64)> {
let doc = u.app.session.doc().unwrap();
// In the parent's coordinates: the layer re-fits its box after an edit.
let t = doc.node(id).unwrap().transform;
figcraft_engine::vectoredit::network(doc, id).unwrap().vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect()
}
#[test]
fn x_reads_mixed_for_points_that_differ_and_typing_sets_every_point() {
let (mut u, id) = setup();
u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap();
let before = xs(&u, id);
// Points 0 and 1 share their y but not their x.
assert_eq!(field(&mut u, "X").1, "Mixed");
assert_eq!(field(&mut u, "Y").1, "0");
type_into(&mut u, "X", "-200", Key::Enter);
let after = xs(&u, id);
assert_eq!((after[0].0, after[1].0), (-200.0, -200.0));
assert_eq!((after[0].1, after[1].1), (before[0].1, before[1].1));
assert_eq!(field(&mut u, "X").1, "-200", "the points share an x now");
// A relative edit of a Mixed field goes to each point from its own value.
u.app.session.execute("vector.edit.select", &json!({"vertices": [1, 2]})).unwrap();
assert_eq!(field(&mut u, "X").1, "Mixed");
let b = xs(&u, id);
type_into(&mut u, "X", "+10", Key::Enter);
let a = xs(&u, id);
assert_eq!((a[1].0, a[2].0), (b[1].0 + 10.0, b[2].0 + 10.0));
}
#[test]
fn one_point_shows_its_own_value() {
let (mut u, id) = setup();
u.app.session.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap();
let p = xs(&u, id)[2];
assert_eq!(field(&mut u, "X").1, format!("{}", p.0));
assert_eq!(field(&mut u, "Y").1, format!("{}", p.1));
}
#[test]
fn a_shared_value_scrubs_every_point_in_one_step_and_mixed_does_not_scrub() {
let (mut u, id) = setup();
// Points 0 and 1 share their y: a scrub moves both to the same new y, as one undo step.
u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap();
let (before, steps) = (xs(&u, id), undo_steps(&u));
scrub(&mut u, "Y", 20.0, Modifiers::NONE);
let after = xs(&u, id);
assert!(after[0].1 > before[0].1, "the scrub moved y: {before:?} -> {after:?}");
assert_eq!(after[0].1, after[1].1, "both points take the scrubbed value");
assert_eq!((after[0].0, after[1].0), (before[0].0, before[1].0), "x is untouched");
assert_eq!(undo_steps(&u), steps + 1, "one undo step for the whole scrub");
// Their x differs (Mixed): there is no value to scrub from, so nothing moves.
assert_eq!(field(&mut u, "X").1, "Mixed");
let (before, steps) = (xs(&u, id), undo_steps(&u));
scrub(&mut u, "X", 20.0, Modifiers::NONE);
assert_eq!((xs(&u, id), undo_steps(&u)), (before, steps));
}
+6 -3
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 13: a boolean group's corner radius (#2222); gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **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
@@ -383,14 +383,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock).
### 13. Vector tail: none left
- **Landed (2026-11-10):** a boolean group takes a corner radius (and smoothing) that rounds every sharp
corner of its result, convex and concave, with the children's own radii kept and a radius larger
than the edges allow shrinking to fit (#2222; `crates/pathops/src/round_tests.rs`).
- **Landed (2026-10-22):** the transform box around points selected in several layers at once, in
the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags
them, each in its own layer and one undo step for all (`transform_tests`).
- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together,
sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG,
PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a
selected open path. Left: corner radius and smoothing on boolean groups that contain
rectangles, per-point caps and joins on dashed, variable-width and brush
selected open path. Left: per-point caps and joins on dashed, variable-width and brush
strokes.
- **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
@@ -593,6 +595,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Gap 13: a boolean group's corner radius rounds every sharp corner of its result (#2222); the boolean clause leaves the 2026-10-12 "Left" list |
| 2026-11-10 | minor | Gap 24: mind maps, first step (`board.mindMap`, `board.mindMapAdd`; the map lays itself out again after every edit, #2226); the toolbar entry, shortcuts, drag to re-parent and `.jam` import are what is missing of them. |
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
| 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. |
+1 -1
View File
@@ -767,7 +767,7 @@ page line crosses; parts that come apart move to layers of their own above, list
`vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing),
`vector.edit.cornerRadius` (rounds the selected sharp points, next to straight or curved segments: a
curve is cut where the arc meets it and keeps the rest of its shape; drawn and exported;
`node.set {cornerRadius}` on a vector rounds every point), `vector.edit.strokeCap {cap, vertices?}`
`node.set {cornerRadius}` on a vector rounds every point, and on a boolean group every sharp corner of its result, while its layers keep their own radius), `vector.edit.strokeCap {cap, vertices?}`
and `vector.edit.strokeJoin {join, vertices?}` (the stroke's cap where it ends at these points and
its join where it turns there, `null` for the layer's; drawn, exported and outlined),
`vector.edit.moveHandles {dx, dy, handles?}` (moves the selected handles by a page offset),
+4 -2
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessiLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode Line truncated
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
@@ -99,7 +99,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 1 | Canvas & navigation | 6 | 98% | 74% | Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks; outline mode's Include hidden layers and Include object bounds landed) | 1–4 |
| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 |
| 4 | Shapes & vector | 8 | 99% | 73% | Corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 |
| 4 | Shapes & vector | 8 | 99% | 73% | per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation, a boolean group's corner radius and the miter angle landed) | 1–3 |
| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 |
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed; a background blur or glass exported on its own keeps the content behind it within its bounds, measured 2026-11-10, #2213) | 2–5 |
@@ -649,6 +649,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | M4, #2222: a boolean group's corner radius rounds every sharp corner of its result (row 4). Numbers unchanged |
| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. |
| 2026-11-11 | patch | M4: typing a number, a full stop and a space at the start of a plain paragraph starts a numbered list at that number (`5. ` then "x", Enter, "y" reads 5. then 6.), as measured in Figma web on 2026-11-11 (it has no start-number field: the start comes only from the typed number); before, only `1. ` and `1)` started a list. `0. `, `12a. ` and a bracket after a number other than 1 stay text (not measured), and the number is capped at 9999. Also measured: a plain paragraph or a bullet item between numbered items restarts the count at 1, and an indented sub-item does not break the outer count; those rows were pinned as silent. Tests: `text_tail_tests::typed_list_starts`, `typed_number_starts_the_list_there`, `tail_tests` restart rows. Numbers unchanged |
| 2026-11-11 | patch | M2.1: grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) (this replaces the 2026-10-11 rule from the documentation). Percentages unchanged |
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
+11 -4
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or thLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indLine truncated
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:
@@ -65,7 +65,7 @@ interaction feel not yet compared.
| Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) |
| Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming |
| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`) |
| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation |
| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation; Shift lands the rotation on multiples of 15° (measured in Figma: a layer at 7° or -4.27° ends at 15°), not 15° steps from where the drag began; layers with different rotations step the turn from 0 |
| Flip, rotate 90° | done | |
| Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) |
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) |
@@ -96,10 +96,11 @@ interaction feel not yet compared.
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate, in the layer being edited (measured in Figma on 2026-11-08). Not yet: areas of other layers edited at once; the Point section's X and Y over points of several edited layers |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). Not yet: areas of other layers edited at once |
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| 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 | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) |
| 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 | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003); a boolean group takes a corner radius from the Design panel and from `node.set cornerRadius`: every sharp corner of its result rounds, convex and concave, layers inside keep their own radius and do not stack with it, a radius larger than the edges allow shrinks to fit while the field keeps the value typed, Flatten bakes the arcs and a mixed selection reads Mixed; measured in Figma's web app on 2026-11-10 (#2222; `crates/pathops/src/round_tests.rs`, `crates/render/src/boolean_radius_tests.rs`, `crates/engine/src/boolean_radius_tests.rs`, the boolean group test in `crates/ui-egui/src/field_tests.rs`). Pinned and not yet compared with Figma: corner smoothing on a boolean group draws the polygon profile between straight edges, a corner beside a curve stays within the curve piece next to it, and there is no radius handle on the canvas |
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. At three levels (the text layer, a paragraph and a text style, per Explore text properties): the paragraph level is the caret's or the selection's paragraphs while editing (`text.wrapStyle`; the control follows them), or `start`/`end` on `node.set wrapStyle` (a paragraph's own style beats the layer's; setting the layer sets every paragraph again; Enter carries it to the new paragraph; inspect reports `lineWrapStyles` when paragraphs differ and CSS `text-wrap` only when they agree); the text-style level is a style's `wrapStyle` (shown read-only in the style editor, applied with the style, moved by `style.update`, unlinking when edited). The `.fig` import/export mapping stays open (#1827): a `.fig` export leaves the wrap styles out and a `.fig` import reads every text as Auto, so the wrap styles are lost through a `.fig` round trip. Test: `text_behaviour_tests::wrap_style_per_paragraph_overrides_the_layers`, `styles_behaviour_tests::text_styles_carry_the_wrap_style`, `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Developer Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` |
@@ -115,6 +116,7 @@ interaction feel not yet compared.
| Item | Status | Notes |
|---|---|---|
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); Preferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only |
| Frame labels (rotated, right-to-left), size badge | done | |
@@ -128,7 +130,7 @@ interaction feel not yet compared.
| Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) |
| Gradient and padding/gap handles on the canvas | done | the gradient's handles and stops show while its picker is open, and dragging one keeps the picker open (#1852) |
| Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots; the outline follows the layer's geometry, rounded corners (each radius and the smoothing) included, whatever the stroke alignment (#1506) |
| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side |
| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools, drawn dark with a white edge so it shows on any canvas colour whatever the system's pointer style (#2198); the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side |
| Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions |
## Keyboard shortcuts
@@ -211,6 +213,11 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | patch | M1, #2198: with a shape, frame, slice or Text tool the pointer was the system's crosshair, white under some pointer styles on Windows and lost on the default white frame; it is now drawn at the pointer in dark lines with a white edge, open at the centre (`Glyph::Crosshair`), so it shows on any canvas colour. |
| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result (measured in Figma's web app), Boolean groups row |
| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. |
| 2026-11-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. |
| 2026-11-07 | patch | Transform: Shift while rotating lands on absolute multiples of 15°, measured in Figma (a layer at 7° or -4.27° Shift-rotated by its corner ends at 15°); FigCraft rounded the turn since the drag began, so 7° landed on 7 + k*15. The gesture keeps the selection's shared rotation and rounds base + turn; a selection with different rotations keeps rounding the turn. Test: `shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start` |
| 2026-11-10 | patch | M1 review follow-up: strengthen canvas alpha, clipping/coverage, grid geometry/order and drawn-theme tests; use settled fresh sidebars at each width, check HEX visibility and opacity alignment, and assert picker toggling preserves page alpha. Production behavior unchanged. |
| 2026-11-09 | patch | M1: canvas page transparency uses a checkerboard that follows light/dark themes and applies background alpha once, including partial or missing renders. The page control also fits both sidebar width limits. Tests: `canvas::background::tests`, `field_tests::page_background_colour_and_opacity_fit_a_resized_sidebar`. Numbers unchanged |
| 2026-11-09 | patch | M1: the Page background reuses the shared sidebar paint control; fractional opacity and picker toggling covered alongside alpha-aware HEX and scrub undo. Audited remaining custom sidebar colour controls. Numbers unchanged |