Paths: Reverse Path Direction turns the stroke's width profile and pen records with the path

Reverse Path Direction reversed the anchors but left the stroke's width
profile, pressure, tilt, bearing and rotation running the old way, so a
tapered or pressure-drawn stroke changed shape. They now turn with the
path through the blend code's stroke reorientation (the width profile's
sides swap), and the stroke draws as before. A path whose subpaths do
not all turn alike keeps its stroke as it was, as blends do. Arrowheads
stay at the path's start and end.
This commit is contained in:
dotCooCoo
2026-11-13 21:05:44 -08:00
parent 1e4e3534f5
commit cf250db2e8
4 changed files with 111 additions and 24 deletions
+38 -9
View File
@@ -606,9 +606,8 @@ fn reoriented<V: Copy>(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wr
out
}
/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap
/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its
/// arrowheads.
/// A stroke read along its subpath turned as `o` says: its width profile (whose sides swap when
/// reversed), pen pressure, tilt, bearing and rotation. Its arrowheads stay as they are.
fn reorient_stroke(s: &mut StrokeLayer, o: Orient) {
if o.is_identity() {
return;
@@ -648,15 +647,45 @@ fn reorient_stroke(s: &mut StrokeLayer, o: Orient) {
let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same);
p.points = out;
}
if o.reversed && !o.closed {
std::mem::swap(&mut s.start_arrow, &mut s.end_arrow);
s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0);
}
/// `n` with every stroke read along its subpaths turned as `o` says, and on a reversed open path
/// its arrowheads swapped.
fn reorient_strokes(n: &mut Node, o: Orient) {
if o.is_identity() {
return;
}
for it in n.appearance.items.iter_mut() {
if let AppearanceItem::Stroke(s) = it {
reorient_stroke(s, o);
if o.reversed && !o.closed {
std::mem::swap(&mut s.start_arrow, &mut s.end_arrow);
s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0);
}
}
}
}
/// `n` with every stroke read along its subpaths turned as `o` says.
fn reorient_strokes(n: &mut Node, o: Orient) {
if o.is_identity() {
/// 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.
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 mut o = Orient::of(sp);
if reversed.get(i).copied().unwrap_or(false) {
if sp.closed {
o.start_at(sp, sp.anchors.len().saturating_sub(1));
}
o.reverse();
}
o
});
let Some(o) = turns.next() else { return };
if o.is_identity() || !turns.all(|t| t.same_turn(&o)) {
return;
}
for it in n.appearance.items.iter_mut() {
+16 -14
View File
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 → {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 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
),
@@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
let mut changed = 0;
for id in &ids {
let path = path_mut(d, *id)?;
if let Some(si) = subpath {
if let Some(sp) = path.subpaths.get_mut(si) {
let flip: Vec<bool> = 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(),
};
if !flip.contains(&true) {
continue;
}
// The stroke's width profile and pen recordings turn with the path, read before it turns.
if let Some(n) = d.node_mut(*id) {
vectorcraft_doc::blend::reverse_stroke_records(n, &flip);
}
let path = path_mut(d, *id)?;
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
changed += 1;
}
} else {
let flip: Vec<bool> = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect();
if flip.contains(&true) {
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
}
}
changed += 1;
}
}
changed += 1;
}
Ok(changed)
})?;
+55
View File
@@ -163,6 +163,61 @@ fn reverse_path_direction_on_and_off_set_the_winding() {
assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse");
}
#[test]
fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path() {
use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile};
let mut s = session();
let open =
NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 50}, {"x": 40, "y": 50}, {"x": 100, "y": 50}]}))["id"].as_u64().unwrap());
let closed = rect(&mut s, 10.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 [open, closed] {
let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap();
st.profile = Some(profile.clone());
st.pressure = Some(pressure.clone());
st.tilt = Some(pressure.clone());
st.bearing = Some(bearing.clone());
st.rotation = Some(bearing.clone());
}
Ok(())
})
.unwrap();
// (anchor point, its fraction of the path's length) for each anchor.
let along = |s: &Session, id: NodeId| {
let sp = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
let pos = sp.anchor_arc_positions();
let total = *pos.last().unwrap();
sp.anchors.iter().zip(pos).map(|(a, at)| (a.p, at / total)).collect::<Vec<_>>()
};
let before = [along(&s, open), along(&s, closed)];
run(&mut s, "select.set", json!({"ids": [open.0, closed.0]}));
assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 2);
for ((id, before), closed) in [open, closed].into_iter().zip(before).zip([false, true]) {
let after = along(&s, id);
let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone();
// Each anchor keeps the widths it had (its sides swapped, as the path runs the other way)
// and what the pen recorded there. (A closed path's start is where its profile steps.) A
// closed path now starts part way along a span of the width profile, which is resampled
// there, so its smooth curve moves a little.
let near = if closed { 0.02 } else { 1e-6 };
for (p, t) in before.iter().filter(|(_, t)| !closed || *t > 0.0) {
let u = after.iter().find(|(q, _)| q.distance(*p) < 1e-9).unwrap().1;
let (l, r) = profile.at(*t);
let (l2, r2) = st.profile.as_ref().unwrap().at(u);
assert!((l - r2).abs() < near && (r - l2).abs() < near, "{id:?} at {p:?}: {:?} then {:?}", (l, r), (l2, r2));
for got in [&st.pressure, &st.tilt] {
assert!((pressure.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} pressure at {p:?}");
}
for got in [&st.bearing, &st.rotation] {
assert!((bearing.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} bearing at {p:?}");
}
}
}
}
#[test]
fn attributes_survive_a_native_round_trip() {
let mut s = session();
+2 -1
View File
@@ -5000,7 +5000,8 @@ 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). SVG export wraps an object with a URL in `<a xlink:href>`, and SVG import reads `<a href>` links back.
(false). When every subpath of a 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 `<a xlink:href>`, and SVG import reads `<a href>` links back.
```json
{"name":"run_command","arguments":{"command":"attributes.set","params":{"ids":[12],"url":"https://example.com","imageMap":"rectangle"}}}