Merge pull request #2241 from TrevorBrown-dev/fix/mirror-sharp-point-2110

M4: Vector edit: a mirror mode turns a sharp point smooth (#2110)
This commit is contained in:
BFlat authored and GitHub committed 2026-11-13 22:24:58 +07:00
commit b98f3dd39f
4 files changed
+145 -4

No files matched your search

+119
View File
@@ -124,9 +124,62 @@ pub fn move_handles(net: &mut VectorNetwork, hs: &[(usize, bool)], d: Vec2, inde
}
}
/// Each handle of a sharp point turned smooth by a mirror mode is this fraction of the length of its
/// own segment. Measured in the incumbent (0.3905 and 0.390 at two angles): known to about 3 digits only.
const MIRROR_SMOOTH_FACTOR: f64 = 0.3905;
/// Give a sharp point (two segments, no handles) the handles a mirror mode makes in the incumbent.
/// Both lie on one line through the point, along `(B - A) / |BA|^2 + (C - B) / |BC|^2` (A, C: the far
/// ends of the two segments). "Angle": each is [`MIRROR_SMOOTH_FACTOR`] x its own segment.
/// "Angle and length": both are that factor x the shorter segment (medium confidence: the
/// measurement could not separate this from other rules). Open-path ends, points with a handle
/// already and coincident neighbours are left alone. Returns whether handles were made.
fn smooth_sharp_point(net: &mut VectorNetwork, vi: usize, mode: HandleMirroring) -> bool {
if mode == HandleMirroring::None {
return false;
}
let inc = net.incident(vi);
let [(s1, a1), (s2, a2)] = inc.as_slice() else { return false };
let (Some(h1), Some(h2)) = (net.handle(*s1, *a1), net.handle(*s2, *a2)) else { return false };
if tvec(h1).hypot() > 1e-9 || tvec(h2).hypot() > 1e-9 {
return false;
}
let far = |s: usize, st: bool| net.segments.get(s).and_then(|x| net.vertices.get(if st { x.b } else { x.a })).map(|v| v.point());
let (Some(b), Some(a), Some(c)) = (net.vertices.get(vi).map(|v| v.point()), far(*s1, *a1), far(*s2, *a2)) else { return false };
let (ab, bc) = (b - a, c - b);
let (la, lc) = (ab.hypot(), bc.hypot());
if !(la > 1e-9 && lc > 1e-9 && la.is_finite() && lc.is_finite()) {
return false;
}
let d = ab / (la * la) + bc / (lc * lc);
if !finite(d) || d.hypot() < 1e-12 {
return false;
}
let d = d.normalize();
let (l_in, l_out) = if mode == HandleMirroring::AngleAndLength {
let l = MIRROR_SMOOTH_FACTOR * la.min(lc);
(l, l)
} else {
(MIRROR_SMOOTH_FACTOR * la, MIRROR_SMOOTH_FACTOR * lc)
};
let (in_h, out_h) = (-d * l_in, d * l_out);
if !(finite(in_h) && finite(out_h)) {
return false;
}
net.set_handle(*s1, *a1, (in_h.x, in_h.y));
net.set_handle(*s2, *a2, (out_h.x, out_h.y));
true
}
/// Set the mirroring of vertices and make their handles agree with it.
pub fn set_mirroring(net: &mut VectorNetwork, verts: &[usize], mode: HandleMirroring) {
for &vi in verts {
if smooth_sharp_point(net, vi, mode) {
if let Some(v) = net.vertices.get_mut(vi) {
v.mirror = Some(mode);
}
continue;
}
let inc = net.incident(vi);
if let [(s1, a1), (s2, a2)] = inc.as_slice()
&& let (Some(h1), Some(h2)) = (net.handle(*s1, *a1), net.handle(*s2, *a2))
@@ -949,6 +1002,72 @@ mod tests {
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
}
/// An open path A, B, C (the measured triangle of #2110) with B sharp.
fn triangle_path() -> VectorNetwork {
VectorNetwork::from_points(&[Point::new(-250.0, -174.0), Point::new(117.0, -174.0), Point::new(-67.0, 194.0)], false)
}
fn handles_at_b(n: &VectorNetwork) -> (Vec2, Vec2) {
(tvec(n.segments[0].tb), tvec(n.segments[1].ta))
}
#[test]
fn mirror_modes_smooth_a_sharp_point_as_measured() {
// Mirror angle and length: both handles 0.3905 x the shorter segment (367), opposite.
let mut n = triangle_path();
set_mirroring(&mut n, &[1], HandleMirroring::AngleAndLength);
let (i, o) = handles_at_b(&n);
assert!((o - Vec2::new(86.24, 114.47)).hypot() < 0.1, "{o:?}");
assert!((i + o).hypot() < 1e-9, "{i:?} {o:?}");
assert!((o.hypot() - 143.3).abs() < 0.1, "{}", o.hypot());
// Mirror angle: each handle 0.3905 x its own segment; still collinear.
let mut n = triangle_path();
set_mirroring(&mut n, &[1], HandleMirroring::Angle);
let (i, o) = handles_at_b(&n);
assert!((i - Vec2::new(-86.24, -114.47)).hypot() < 0.1, "{i:?}");
assert!((o - Vec2::new(96.69, 128.33)).hypot() < 0.1, "{o:?}");
assert!((i.x * o.y - i.y * o.x).abs() < 1e-6, "collinear");
assert!((i.hypot() - 143.3).abs() < 0.1 && (o.hypot() - 160.7).abs() < 0.1);
assert_eq!(n.mirroring(1), HandleMirroring::Angle);
}
#[test]
fn mirror_modes_smooth_other_angles_and_several_points() {
// A right angle at B with legs 100 and 50: d = (1,0)/100 + (0,1)/50, so the line leans to +y.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0)], false);
set_mirroring(&mut n, &[1], HandleMirroring::Angle);
let (i, o) = handles_at_b(&n);
let want = Vec2::new(0.01, 0.02).normalize();
assert!((o.normalize() - want).hypot() < 1e-9, "{o:?}");
assert!((o.hypot() - 19.525).abs() < 1e-9 && (i.hypot() - 39.05).abs() < 1e-9, "{i:?} {o:?}");
// Two sharp points of a closed square, both selected: both become smooth.
let mut sq = square();
set_mirroring(&mut sq, &[0, 2], HandleMirroring::AngleAndLength);
for v in [0, 2] {
assert!(sq.incident(v).iter().all(|(s, st)| sq.handle(*s, *st).is_some_and(|h| tvec(h).hypot() > 1.0)), "vertex {v}");
assert_eq!(sq.mirroring(v), HandleMirroring::AngleAndLength);
}
assert!(sq.segments.iter().all(|s| tvec(s.ta).x.is_finite() && tvec(s.tb).y.is_finite()));
}
#[test]
fn mirror_modes_leave_unmeasured_points_alone() {
// Open-path ends have only one segment.
let mut n = open_line();
set_mirroring(&mut n, &[0, 2], HandleMirroring::Angle);
assert!(n.segments.iter().all(|s| s.is_straight()));
// A zero-length neighbour: stays sharp, finite.
let mut z = VectorNetwork::from_points(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0), Point::new(60.0, 0.0)], false);
set_mirroring(&mut z, &[1], HandleMirroring::AngleAndLength);
assert!(z.segments.iter().all(|s| s.is_straight()));
// One handle already: the old behaviour (the lone handle stays, the other mirrors it).
let mut h = open_line();
h.segments[1].ta = (10.0, 5.0);
set_mirroring(&mut h, &[1], HandleMirroring::Angle);
assert_eq!(h.segments[1].ta, (10.0, 5.0));
assert_eq!(h.segments[0].tb, (-10.0, -5.0));
}
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
let ends: Vec<(usize, usize)> = n
.loop_walk(lp)
@@ -407,6 +407,24 @@ fn mirroring_position_and_join_commands() {
assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok());
}
#[test]
fn mirror_mode_on_sharp_points_is_one_undo_step() {
// #2110: a mirror mode turns sharp points smooth; the whole command undoes in one step.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[-250, -174], [117, -174], [-67, 194]], "closed": false}));
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": [1]}));
ex(&mut s, "vector.edit.mirroring", json!({"mode": "angleAndLength"}));
let n = net(&s, id);
let (i, o) = (n.segments[0].tb, n.segments[1].ta);
assert!((o.0 - 86.24).abs() < 0.1 && (o.1 - 114.47).abs() < 0.1, "{o:?}");
assert!((i.0 + o.0).abs() < 1e-9 && (i.1 + o.1).abs() < 1e-9, "{i:?}");
s.undo().unwrap();
let n = net(&s, id);
assert!(n.segments.iter().all(|x| x.is_straight()), "one undo restores the sharp point");
}
#[test]
fn a_point_dropped_on_another_merges_into_it() {
// #401: an open path's end dragged onto its start closes the path at that point.
@@ -215,9 +215,12 @@ fn corner_radius_mirroring_and_delete(s: &mut Session, a: NodeId, b: NodeId, atl
fn bend_select_all_pen_and_clicks(s: &mut Session, a: NodeId, b: NodeId) {
// The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer.
// (The earlier mirroring step already turned the selected corners smooth, #2110.)
let a_before = net(s, a).segments;
let b_before = net(s, b).segments;
drag(s, (250.0, 100.0), (250.0, 130.0), CMD);
assert!(net(s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
assert!(net(s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
assert!(net(s, b).segments != b_before, "the bend lands on the layer under the pointer");
assert!(net(s, a).segments == a_before, "other layers keep their segments");
// Select all takes every edited layer's points.
let r = ex(s, "vector.edit.selectAll", json!({}));
assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)");
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; 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 resuLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; 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 tLine 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:
@@ -97,7 +97,7 @@ interaction feel only partly compared: the interactions measured so far, case by
| 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), 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); 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); 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; 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 mirror mode on a sharp point turns it smooth by the measured rule (#2110); 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); 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); 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; the Point section's X and Y over points of several edited layers |
| 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) |
| 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; the caret takes the text's solid fill colour, measured in Figma's web app, 2026-11-09; gradient and image fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour, which is white in the dark theme and vanished on the default light page (#2190) (#2156; `caret_tests`); no spell check |
@@ -215,6 +215,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | patch | M4, #2110: in vector edit mode, choosing Mirror angle or Mirror angle and length for a sharp point (two segments, no handles) makes it smooth, as measured in Figma: both handles on one line through the point, along `(B - A) / \|BA\|^2 + (C - B) / \|BC\|^2` (A and C the far ends of its segments), each 0.3905 x its own segment for angle, both 0.3905 x the shorter segment for angle and length (the factor is known to about three digits; the length rule is medium confidence); several selected points each become smooth. Open-path ends, points that already have a handle, points with a coincident neighbour, and points whose two far ends coincide (A = C, so the direction is zero) keep the old behaviour. Evidence: tests in `crates/engine/src/canvas/vectoredit/ops.rs` and `tests.rs` |
| 2026-11-12 | patch | M8: Tab while renaming the last layer in the Layers panel commits and opens the first row's rename, as measured in Figma's web app on 2026-11-12 (it ended the rename before); Shift+Tab on the first row is unchanged, not measured; Tab and Shift+Tab now move only the rename and leave the selection on the layer first renamed, as measured in Figma's web app on 2026-11-12 (it followed the rename before) (`tab_on_the_last_row_commits_and_wraps_to_the_first`, `tab_while_renaming_leaves_the_selection_on_the_first_layer`), Rename row |
| 2026-11-09 | patch | M3, #2156: the text caret takes the text's solid fill colour (red text, red caret; light grey on a dark frame, light grey, one colour and no inversion), measured in Figma's web app: the fill of the character before the caret (the first one's at the start); FigCraft draws it opaque (how Figma treats a translucent fill was not measured); gradient, image and hidden fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour (white in the dark theme, invisible on the default light page, #2190). Width and height unchanged; collaborators' carets unchanged. Tests: `caret_tests` |
| 2026-11-10 | minor | The Rotation field shows and sets the layer's rotation on the page (its own angle plus its parents' when no ancestor is flipped; with a flipped ancestor FigCraft reads the composed angle, which flips the sign of the layer's own angle, and Figma's behaviour there was not measured), in frames and groups alike, as measured in Figma's web app for rotated, unflipped parents (#2196) |