diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index b84de1cac3..56764b6426 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -606,9 +606,8 @@ fn reoriented(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() { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 33bde936bf..34f41eda24 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 → {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 { 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 = 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 = 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) })?; diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index d3edf2615b..8ddfc352a1 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -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::>() + }; + 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(); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..ab357c50d6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 ``, and SVG import reads `` 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 ``, and SVG import reads `` links back. ```json {"name":"run_command","arguments":{"command":"attributes.set","params":{"ids":[12],"url":"https://example.com","imageMap":"rectangle"}}}