From 5fcccce7ce0cd069fa39cf8cb41df78d6f680aff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 14:43:01 -0800 Subject: [PATCH] Paths: Reverse Path Direction turns a compound path's own stroke width profile and pen records Reverse Path Direction reversed each member path of a compound path and turned the members' own stroke records, but left the compound path's strokes as they were. A compound path's strokes run along its members' subpaths in order, so its width profile and pen pressure, tilt, bearing and rotation then ran the old way and the stroke drew differently. reverse_stroke_records now also takes a compound path, reading its members' subpaths in order, and path.reverse turns each compound path's strokes with the combined reversal of its members before they reverse. As for a path, the strokes turn only when every subpath turns alike. --- crates/doc/src/blend.rs | 20 +++++++++------- crates/engine/src/cmd/path.rs | 29 +++++++++++++++++++---- crates/engine/src/tests/attributes.rs | 33 +++++++++++++++++++++++++++ docs/mcp.md | 2 +- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 9f9ba151e9..c7c3e408b4 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -840,15 +840,19 @@ fn reorient_strokes(n: &mut Node, o: Orient) { } } -/// Turn the strokes of path `n` for Reverse Path Direction, before its subpaths flagged in -/// `reversed` are reversed with [`SubPath::reverse`] (a closed one then starts at its last -/// anchor): each stroke's width profile (sides swapped), pen pressure, tilt, bearing and rotation -/// then run the new way, so the stroke draws as it did. A stroke reads one profile along every -/// subpath, so it turns only when all of them turn alike. Arrowheads stay at the path's start and -/// end. +/// Turn the strokes of path or compound path `n` for Reverse Path Direction, before its subpaths +/// flagged in `reversed` are reversed with [`SubPath::reverse`] (a closed one then starts at its +/// last anchor): each stroke's width profile (sides swapped), pen pressure, tilt, bearing and +/// rotation then run the new way, so the stroke draws as it did. A compound path's subpaths are +/// its members' subpaths in order. A stroke reads one profile along every subpath, so it turns +/// only when all of them turn alike. Arrowheads stay at the path's start and end. pub fn reverse_stroke_records(n: &mut Node, reversed: &[bool]) { - let NodeKind::Path { path, .. } = &n.kind else { return }; - let mut turns = path.subpaths.iter().enumerate().map(|(i, sp)| { + let subpaths: Vec<&SubPath> = match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().collect(), + NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).flat_map(|pd| &pd.subpaths).collect(), + _ => return, + }; + let mut turns = subpaths.into_iter().enumerate().map(|(i, sp)| { let mut o = Orient::of(sp); if reversed.get(i).copied().unwrap_or(false) { if sp.closed { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 2ad2b70049..0fb450fa43 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Reverse Path Direction", ["Object", "Path"], None, - "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}", + "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path or compound path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}", has_selected_path, reverse ), @@ -458,14 +458,33 @@ fn reverse(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select paths")); } + let flip_of = |path: &PathData| -> Vec { + match subpath { + Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(), + None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(), + } + }; let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| { + // A compound path's own strokes run along its members' subpaths in order, so they turn + // with them, read before any member turns. + let compounds: BTreeSet = ids.iter().map(|id| compound_or_self(d, *id)).filter(|c| ids.binary_search(c).is_err()).collect(); + for c in compounds { + let Some(NodeKind::Compound { children, .. }) = d.node(c).map(|n| &n.kind) else { continue }; + let flip: Vec = children + .iter() + .filter_map(|m| m.path_data().map(|pd| if ids.binary_search(&m.id).is_ok() { flip_of(pd) } else { vec![false; pd.subpaths.len()] })) + .flatten() + .collect(); + if flip.contains(&true) + && let Some(n) = d.node_mut(c) + { + vectorcraft_doc::blend::reverse_stroke_records(n, &flip); + } + } let mut changed = 0; for id in &ids { let path = path_mut(d, *id)?; - let flip: Vec = match subpath { - Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(), - None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(), - }; + let flip = flip_of(path); if !flip.contains(&true) { continue; } diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index 8ddfc352a1..b6d00111e2 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -218,6 +218,39 @@ fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path( } } +#[test] +fn reverse_path_direction_turns_a_compound_paths_own_width_profile_and_pen_records() { + use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile}; + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 50.0)); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + run(&mut s, "object.compoundPath.make", json!({})); + let compound = s.doc().unwrap().selection.objects[0]; + // A lone rectangle of the same shape, whose strokes Reverse Path Direction already turns. + let lone = rect(&mut s, 0.0); + let profile = WidthProfile { points: vec![(0.0, 0.2, 0.6), (0.3, 1.0, 0.4), (1.0, 0.5, 0.9)] }; + let pressure = PressureProfile { points: vec![(0.0, 0.1), (0.25, 0.9), (1.0, 0.4)] }; + let bearing = AngleProfile { points: vec![(0.0, 10.0), (0.6, 80.0), (1.0, 170.0)] }; + s.edit("set", |d, _| { + for id in [compound, lone] { + let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap(); + st.profile = Some(profile.clone()); + st.pressure = Some(pressure.clone()); + st.bearing = Some(bearing.clone()); + } + Ok(()) + }) + .unwrap(); + run(&mut s, "select.set", json!({"ids": [compound.0, lone.0]})); + assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 3); + let stroke = |id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone(); + let (got, want) = (stroke(compound), stroke(lone)); + assert_ne!(want.profile.as_ref(), Some(&profile), "the lone rectangle's profile turned"); + assert_eq!(got.profile, want.profile, "both members turned alike, so the compound path's profile turns as the rectangle's does"); + assert_eq!(got.pressure, want.pressure); + assert_eq!(got.bearing, want.bearing); +} + #[test] fn attributes_survive_a_native_round_trip() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f7606a5b11..d93eb92709 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5214,7 +5214,7 @@ The Attributes panel (`window.panel {panel: "attributes"}`, Cmd+F11) reads `attr objects differ) and `recentUrls`. `attributes.set {overprintFill?, overprintStroke?, showCenter?, imageMap?, url?, note?, ids?}` sets them in one undo step; `path.setFillRule {rule: "nonZero"|"evenOdd"}` sets the fill rule of the paths and compound paths, and `path.reverse {reversed?}` makes subpaths run counter-clockwise (true) or clockwise -(false). When every subpath of a path turns alike, its strokes' width profiles (left and right swapped) and pen +(false). When every subpath of a path or compound path turns alike, its strokes' width profiles (left and right swapped) and pen pressure, tilt, bearing and rotation turn with it, so the strokes draw as before. SVG export wraps an object with a URL in ``, and SVG import reads `` links back. ```json