M8: joining the ends of a path broken open closes its fill loop through the new segment (#1951)

Cause: deleting a point with Delete (not Shift+Delete) opens the path and leaves its region's
loop with a gap, filled closed by a straight line across it; joining the two ends again (the pen
onto the other end, Connect, Join) added the segment to the network but not to the loop, which
still spanned the gap. Unrounded, the straight line and the segment coincide; once the points are
rounded, the fill and the selection outline run straight across while the stroke, walked from the
network itself, follows the segment and its arcs (#1506's picture).

Fix: `ops::connect` splices the new segment into the loop whose segments are the chain between
the two ends, so the loop closes through it; a chain no loop fills still becomes a region of its
own. Tests: the chamfer made by Shift+Delete, by Delete then Connect, and drawn with the pen, each
rounded, with the fill tracing the stroke (engine); every loop order and segment direction of a
chamfered rectangle (doc).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-04 04:35:37 -05:00
1 parent 6f780211dd
commit de881846a8
4 files changed
+151 -4

No files matched your search

+55
View File
@@ -681,6 +681,61 @@ fn two_segment_closed_loop_rounded_corners() {
assert!(!path.elements().is_empty());
}
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the
/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop
/// lists its segments and whichever way each one runs (#1951, #1506).
#[test]
fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
for first in 0..corners.len() {
for backwards in [false, true] {
for flipped in 0..(1u32 << corners.len()) {
let mut net = VectorNetwork::from_points(&corners, true);
for v in &mut net.vertices {
v.radius = 43.0;
}
for (i, s) in net.segments.iter_mut().enumerate() {
if flipped & (1 << i) != 0 {
std::mem::swap(&mut s.a, &mut s.b);
std::mem::swap(&mut s.ta, &mut s.tb);
}
}
let lp = &mut net.regions[0].loops[0];
lp.rotate_left(first);
if backwards {
lp.reverse();
}
let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" });
let stroke = net.stroke_path();
let fill = net.fill_path();
let region = net.rounded().unwrap().region_path(0).unwrap();
for (i, p) in corners.iter().enumerate() {
assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p));
assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
assert!(keeps_off(&region, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(&region, *p));
}
assert!(
(length(&fill) - length(&stroke)).abs() < 1e-6,
"{why}: fill {} and stroke {} trace the same outline",
length(&fill),
length(&stroke)
);
assert!(
(length(&region) - length(&stroke)).abs() < 1e-6,
"{why}: region {} and stroke {} trace the same outline",
length(&region),
length(&stroke)
);
}
}
}
}
#[test]
fn stored_variable_ids_resolve_only_their_exact_collection() {
let mut vars = crate::proto_expr_tests::vars();
+10 -2
View File
@@ -658,8 +658,16 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
let si = net.segments.len() - 1;
if ends && let Some(mut lp) = path_between(net, b, a, si) {
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if filled {
// A loop broken open at these ends (a point of it deleted, say) closes through the new
// segment, so the fill follows it instead of a straight line across the gap (#1951).
for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) {
*l = std::iter::once(si).chain(lp.iter().copied()).collect();
}
}
} else {
lp.insert(0, si);
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
@@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
}
/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner
/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and
/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951).
#[test]
fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() {
for top_first in [true, false] {
let mut s = Session::new();
let id = rect(&mut s);
// Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1.
let (first, second) = if top_first { (0, 1) } else { (1, 0) };
ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5}));
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.deleteHeal", json!({}));
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, &format!("top first {top_first}"));
// A region with a fill of its own is drawn through the region path and the planar normaliser.
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill"));
}
}
/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two
/// new points, and the two connected again; or the pentagon drawn with the pen (#1951).
#[test]
fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions);
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
assert_eq!(net(&s, id).segments.len(), 5, "connected again");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "deleted and connected");
// Drawn with the pen: continue from the end of an open two-point path and close on its start.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "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.tool", json!({"tool": "pen"}));
click(&mut s, 50.0, 0.0);
for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] {
click(&mut s, x, y);
}
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
/// fill that keeps off every sharp point and traces the same outline as the stroke.
fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let n = net(s, id);
assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions);
assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops);
let doc = s.doc().unwrap();
let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
let stroke = n.stroke_path();
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] {
assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p));
assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p));
}
assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
}
+4 -2
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (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 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hostingLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (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); 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 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; 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
@@ -245,7 +245,8 @@ Hours in this narrative are the builders' estimates; the tables above supersede
pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any
closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and
length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and
merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
merge (joining the two ends of a path broken open by a deleted point closes it again, so its
fill follows the new segment, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
(⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept),
variable width strokes (width profile presets with previews in the stroke settings, the Variable
width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete;
@@ -648,6 +649,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` splices it into the loop whose segments are the chain between the ends. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). |
| 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged |
| 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged |
| 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged |