diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 993e45066..9e7b9b372 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -661,10 +661,15 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb: 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). + // segment, so the fill follows it instead of a straight line across the gap (#1951). The + // segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a + // hole wound against its outline stays a hole whichever end was clicked first. + let segs = &net.segments; + let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b); 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(); + let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len()); + l.insert(gap, si); } } } else { @@ -1005,6 +1010,37 @@ mod tests { assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none()); } + #[test] + fn connecting_the_ends_of_a_hole_keeps_its_winding() { + // A ring: the hole's loop walks the other way round from the outline's, so the non-zero + // rule leaves it empty. + let mut n = square(); + for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] { + n.vertices.push(Vertex::new(x, y)); + } + for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] { + n.segments.push(Segment::line(a, b)); + } + n.regions[0].loops.push(vec![7, 6, 5, 4]); + let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0)); + assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with"); + // Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off + // corner is filled), then connect the two open ends again, either end first: the hole is a + // triangle, still empty. + let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap(); + for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] { + let mut m = n.clone(); + delete_vertices(&mut m, &[5], false); + assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions); + let (a, b) = (at(&m, first), at(&m, second)); + connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap(); + let loops = &m.regions[0].loops; + assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions); + assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}"); + assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled"); + } + } + #[test] fn join_merges_overlapping_points_and_connects_ends() { let mut n = open_line(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2093798ed..63f77f30e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -649,7 +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-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` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first. 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 |