Blend: stroke gradients and dashes follow a key path the blend turns

When a blend starts a key's path at another anchor or runs it the other
way, the steps already read that key's width profile and pen recordings
along the turned path. Its stroke gradient and dashes still followed the
key's own direction, so steps near that key showed them flipped or
shifted.

- A gradient along the path starts where the turned path starts and
  runs backwards on a reversed one; a gradient across the path swaps
  sides on a reversed one. Stops that reflect an even number of times
  are left as they are.
- Dashes run backwards on a reversed path, and exact dashes start where
  the turned path starts, when every subpath needs the same offset.
- Two gradients with different stop counts keep a step (two stops at
  one offset) when they are resampled to common stops.
This commit is contained in:
dotCooCoo
2026-11-13 21:16:01 -08:00
parent 1e4e3534f5
commit 2f8301671e
3 changed files with 297 additions and 20 deletions
+293 -16
View File
@@ -19,11 +19,11 @@
use std::f64::consts::PI;
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_color::{Color, Gradient, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint};
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeLayer, WidthProfile};
use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeGradientMode, StrokeLayer, WidthProfile};
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
use crate::node::{Node, NodeId, NodeKind};
use crate::text::TextObject;
@@ -60,7 +60,11 @@ fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) ->
/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops
/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks.
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
let stops = &g.stops;
link_in(&g.stops, t)
}
/// [`link_at`] of a gradient's `stops`.
fn link_in(stops: &[GradientStop], t: f32) -> (Option<String>, f32) {
let link = |s: &GradientStop| (s.swatch.clone(), s.tint);
let Some(first) = stops.first() else { return (None, 1.0) };
if t <= first.offset {
@@ -84,15 +88,30 @@ fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
stops.last().map_or((None, 1.0), link)
}
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred).
/// How close two places a gradient changes are for [`common_stops`] to give them one stop (two
/// where either gradient steps there: two of its stops at the same offset).
const STEP: f32 = 1e-4;
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred). An
/// offset given twice is a step: the second stop paints what `g` steps to there (its last stop
/// within [`STEP`] of it), or what it paints there when it does not step.
fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
let mut out = g.clone();
out.stops = offsets
.iter()
.map(|&o| {
let (color, opacity) = g.sample_keep_model(o);
let (swatch, tint) = link_at(g, o);
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
.enumerate()
.map(|(i, &o)| {
let after = i.checked_sub(1).and_then(|j| offsets.get(j)) == Some(&o);
let mut near = g.stops.iter().filter(|s| (s.offset - o).abs() < STEP);
let stepped = if after && near.next().is_some() { near.next_back() } else { None };
match stepped {
Some(s) => GradientStop { opacity: s.opacity, swatch: s.swatch.clone(), tint: s.tint, ..GradientStop::new(o, s.color) },
None => {
let (color, opacity) = g.sample_keep_model(o);
let (swatch, tint) = link_at(g, o);
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
}
}
})
.collect();
out
@@ -111,7 +130,7 @@ fn gradient_breaks(g: &Gradient, out: &mut Vec<f32>) {
}
/// Two gradients of one kind with the same number of stops: as they are when the counts match,
/// else both resampled at every place either changes.
/// else both resampled at every place either changes, with two stops where either steps.
fn common_stops(a: &Gradient, b: &Gradient) -> Option<(Gradient, Gradient)> {
if a.kind != b.kind {
return None;
@@ -127,8 +146,22 @@ fn common_stops(a: &Gradient, b: &Gradient) -> Option<(Gradient, Gradient)> {
gradient_breaks(b, &mut offs);
offs.retain(|o| o.is_finite());
offs.sort_by(f32::total_cmp);
offs.dedup_by(|x, y| (*x - *y).abs() < 1e-4);
Some((resample_stops(a, &offs), resample_stops(b, &offs)))
offs.dedup_by(|x, y| (*x - *y).abs() < STEP);
let mut steps: Vec<f32> =
[a, b].into_iter().flat_map(|g| g.stops.windows(2).filter(|w| w[1].offset == w[0].offset).map(|w| w[0].offset)).collect();
steps.sort_by(f32::total_cmp);
let mut at = Vec::with_capacity(offs.len() + steps.len());
let mut k = 0;
for o in offs {
at.push(o);
while steps.get(k).is_some_and(|&s| s <= o - STEP) {
k += 1;
}
if steps.get(k).is_some_and(|&s| (s - o).abs() < STEP) {
at.push(o);
}
}
Some((resample_stops(a, &at), resample_stops(b, &at)))
}
fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<GradientPaint> {
@@ -606,13 +639,112 @@ 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.
fn reorient_stroke(s: &mut StrokeLayer, o: Orient) {
/// Gradient `g` laid along a closed subpath that now starts at fraction `s` of its length: the
/// part from `s` on runs first, then the part before it, stepping where the old start now falls.
/// The stops follow its colors as [`Gradient::span`] reads them, mixed linearly, and run once.
/// One of `g`'s own stops keeps its swatch link (each stop of a step its own), and a stop between
/// two of them the link [`link_at`] reads there.
fn gradient_from(g: &Gradient, s: f32) -> Gradient {
let g = g.unrolled();
let mut own = g.stops.clone();
own.sort_by(|a, b| a.offset.total_cmp(&b.offset));
let mut stops = Vec::new();
// The part from `s` to the end runs first, then the part up to `s`.
for (from, to, at) in [(s, 1.0, 0.0), (0.0, s, 1.0 - s)] {
// The first of `own` not yet matched: the parts' stops run in order, so the links are
// found in one pass.
let mut j = 0;
for st in g.span(from, to).stops {
let p = from + (to - from) * st.offset;
while own.get(j).is_some_and(|o| o.offset <= p - STEP) {
j += 1;
}
let hit = (j..own.len())
.take_while(|&i| own.get(i).is_some_and(|o| o.offset < p + STEP))
.find(|&i| own.get(i).is_some_and(|o| o.color == st.color && o.opacity == st.opacity));
let (swatch, tint) = match hit.and_then(|i| own.get(i).map(|o| (i, o))) {
Some((i, o)) => {
j = i + 1;
(o.swatch.clone(), o.tint)
}
None => link_in(own.get(j.saturating_sub(1)..(j + 1).min(own.len())).unwrap_or_default(), p),
};
stops.push(GradientStop { offset: at + (to - from) * st.offset, swatch, tint, ..st });
}
}
Gradient { stops, method: GradientMethod::Classic, spread: GradientSpread::Pad, repeats: 1, ..g.into_owned() }
}
/// Dash pattern `d` read along subpaths of lengths `lens` turned as `o` says. Reversed, the
/// pattern runs backwards: from the start of a dash, that is the dash and then the other entries
/// in reverse order. Exact dashes also start where the turned subpaths now start. The pattern
/// restarts on every subpath at the one offset, so the offset moves only when every subpath needs
/// the same one (as subpaths whose lengths differ by whole periods do); dashes fitted to corners
/// ignore the offset, which stays as it is. A dash pattern starts once per subpath, so on a closed
/// subpath started elsewhere the dashes match the key's as far as its own start, and past it run
/// on without restarting there.
fn reorient_dash(d: &mut Dash, o: Orient, lens: &[f64]) {
if !d.is_dashed() || !d.offset.is_finite() {
return;
}
let mut pat = d.pattern.clone();
// An odd pattern repeats twice per period (dash, gap, dash / gap, dash, gap).
if pat.len() % 2 == 1 {
pat.extend_from_within(..);
}
let period: f64 = pat.iter().sum();
// Where along the key's own subpath the turned one starts (a closed one wraps round).
let at = if o.closed { o.seam() } else { o.start };
let first = pat.first().copied().unwrap_or(0.0);
let offset = |len: f64| if o.reversed { first - at * len - d.offset } else { d.offset + at * len };
let offsets: Vec<f64> = lens.iter().map(|&len| offset(len)).collect();
let agree = offsets.first().is_some_and(|&a| {
offsets.iter().all(|&b| {
let r = (b - a).rem_euclid(period);
r.is_finite() && r.min(period - r) <= 1e-6
})
});
if o.reversed {
if let Some(rest) = pat.get_mut(1..) {
rest.reverse();
}
d.pattern = pat;
}
if let Some(&off) = offsets.first().filter(|_| agree && !d.align_corners) {
d.offset = off;
}
}
/// A key's stroke read along its subpaths turned as `o` says (`lens`: their lengths): its width
/// profile (whose sides swap when reversed), pen pressure, tilt, bearing and rotation, its dashes,
/// a gradient along the path or across it (whose sides swap when reversed), and on a reversed
/// open path its arrowheads.
fn reorient_stroke(s: &mut StrokeLayer, o: Orient, lens: &[f64]) {
if o.is_identity() {
return;
}
if let Some(d) = &mut s.dash {
reorient_dash(d, o, lens);
}
let along = s.gradient_mode == StrokeGradientMode::Along;
if s.path_gradient().is_some()
&& let Paint::Gradient(g) = &mut s.paint
{
let start = along && o.seam() > 0.0;
if start {
g.gradient = gradient_from(&g.gradient, o.seam() as f32);
}
// Stops that reflect an even number of times read the same either way.
let (spread, runs) = g.gradient.painted_spread();
let reverse = o.reversed && !(spread == GradientSpread::Reflect && runs % 2 == 0);
if reverse {
g.gradient.reverse();
}
if start || reverse {
// The turned gradient is no longer the swatch's.
g.swatch = None;
}
}
let same = |v: f64| v;
if let Some(p) = &mut s.profile {
let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect();
@@ -659,9 +791,11 @@ fn reorient_strokes(n: &mut Node, o: Orient) {
if o.is_identity() {
return;
}
let lens: Vec<f64> =
node_path(n).map(|(p, ..)| p.subpaths.iter().map(|sp| PathData::new(vec![sp.clone()]).length()).collect()).unwrap_or_default();
for it in n.appearance.items.iter_mut() {
if let AppearanceItem::Stroke(s) = it {
reorient_stroke(s, o);
reorient_stroke(s, o, &lens);
}
}
}
@@ -2627,6 +2761,149 @@ mod tests {
assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation);
}
#[test]
fn stroke_gradients_and_dashes_turn_with_a_key_path_started_elsewhere() {
let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| {
let mut ap = Appearance::default();
ap.set_stroke(Paint::solid(Color::BLACK));
if let Some(s) = ap.stroke_mut() {
edit(s);
}
Node::path(NodeId(1), path, ap)
};
let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke"));
// How light the stroke's gradient is at `u` (0 black, 3 white).
let light = |s: &StrokeLayer, u: f32| match &s.paint {
Paint::Gradient(g) => g.gradient.sample(u).0.to_rgb().iter().sum::<f32>(),
p => panic!("a gradient: {p:?}"),
};
// Whether dash pattern `d` draws a dash `at` this far along its subpath.
let dash_at = |d: &Dash, at: f64| {
let p = if d.pattern.len() % 2 == 1 { d.pattern.repeat(2) } else { d.pattern.clone() };
let mut x = (at + d.offset).rem_euclid(p.iter().sum());
for (i, v) in p.iter().enumerate() {
if x < *v {
return i % 2 == 0;
}
x -= v;
}
false
};
let graded = |mode: StrokeGradientMode| {
move |s: &mut StrokeLayer| {
s.paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
s.gradient_mode = mode;
s.dash = Some(Dash { pattern: vec![1.0, 2.0, 3.0, 1.5], offset: 0.75, align_corners: false });
}
};
// An open key clicked at its last anchor runs the other way: a gradient along it or across
// it (whose sides swap) runs the other way too, and its dashes are read from its end.
let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 13.0, 0.0)], false));
let plain = keyed(line(0.0), &|_| {});
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
let key = keyed(line(100.0), &graded(mode));
let s = stroke(&Lerp::new(&plain, &key, (None, Some(1))).at(1.0));
assert!(light(&s, 0.0) < 0.01 && light(&s, 1.0) > 2.99, "{mode:?}: {:?}", s.paint);
let (own, d) = (stroke(&key).dash.unwrap_or_default(), s.dash.unwrap_or_default());
for k in 1..130 {
let u = k as f64 / 10.0 + 0.03;
assert_eq!(dash_at(&d, u), dash_at(&own, 13.0 - u), "at {u}: {d:?}");
}
}
// A closed key started half way round starts its gradient along it there too, stepping
// where its own start was, and its dashes start there.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0));
let key = keyed(square(100.0), &graded(StrokeGradientMode::Along));
let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &key, (Some(0), Some(2))).at(1.0));
for (u, want) in [(0.0, 1.5), (0.25, 0.75), (0.499, 0.0), (0.501, 3.0), (0.75, 2.25), (1.0, 1.5)] {
assert!((light(&s, u) - want).abs() < 0.02, "at {u}: {} for {want}: {:?}", light(&s, u), s.paint);
}
let (own, d) = (stroke(&key).dash.unwrap_or_default(), s.dash.unwrap_or_default());
for k in 1..200 {
let u = k as f64 / 10.0 + 0.03;
assert_eq!(dash_at(&d, u), dash_at(&own, u + 20.0), "at {u}: {d:?}");
}
// Stops that reflect twice read the same either way; a link to a swatch stays.
let reflected = keyed(line(100.0), &|s| {
let mut g = Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::default() };
g.stops[1].swatch = Some("Ink".into());
s.paint = Paint::Gradient(Box::new(GradientPaint::new(g)));
s.gradient_mode = StrokeGradientMode::Along;
});
assert_eq!(stroke(&Lerp::new(&plain, &reflected, (None, Some(1))).b).paint, stroke(&reflected).paint);
let linked = keyed(square(100.0), &|s| {
let mut g = Gradient::default();
g.stops[1].swatch = Some("Ink".into());
s.paint = Paint::Gradient(Box::new(GradientPaint::new(g)));
s.gradient_mode = StrokeGradientMode::Along;
});
let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &linked, (Some(0), Some(2))).b);
let links = match &s.paint {
Paint::Gradient(g) => g.gradient.stops.iter().filter(|st| st.swatch.as_deref() == Some("Ink")).map(|st| st.offset).collect::<Vec<_>>(),
p => panic!("a gradient: {p:?}"),
};
assert!(links.contains(&0.5), "the stop the old start ended on keeps its link: {:?}", s.paint);
// Each stop of a step keeps its own link.
let step = keyed(square(100.0), &|s| {
let mut g = Gradient::default();
let (mut before, mut after) = (GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.25, Color::WHITE));
(before.swatch, after.swatch) = (Some("Ink".into()), Some("Paper".into()));
g.stops.splice(1..1, [before, after]);
s.paint = Paint::Gradient(Box::new(GradientPaint::new(g)));
s.gradient_mode = StrokeGradientMode::Along;
});
let s = stroke(&Lerp::new(&keyed(square(0.0), &|_| {}), &step, (Some(0), Some(2))).b);
let links = match &s.paint {
Paint::Gradient(g) => g.gradient.stops.iter().filter(|st| st.offset == 0.75).map(|st| st.swatch.clone()).collect::<Vec<_>>(),
p => panic!("a gradient: {p:?}"),
};
assert_eq!(links, vec![Some("Ink".into()), Some("Paper".into())], "{:?}", s.paint);
// Blended with a gradient of other stops, the steps keep that step.
let three = keyed(square(0.0), &|s| {
let mut g = Gradient::default();
g.stops.insert(1, GradientStop::new(0.5, Color::BLACK));
s.paint = Paint::Gradient(Box::new(GradientPaint::new(g)));
s.gradient_mode = StrokeGradientMode::Along;
});
let s = stroke(&Lerp::new(&three, &key, (Some(0), Some(2))).at(0.75));
assert!(light(&s, 0.501) - light(&s, 0.499) > 1.5, "{:?}", s.paint);
// Subpaths whose lengths call for different dash offsets keep the offset.
let rings = |x: f64, turn: usize| {
let mut inner = shapes::rectangle(Rect::new(x, 20.0, x + 7.0, 27.0)).subpaths.remove(0);
inner.anchors.rotate_left(turn);
PathData::new(vec![square(x).subpaths.remove(0), inner])
};
let key = keyed(rings(100.0, 2), &graded(StrokeGradientMode::Along));
let blend = Lerp::new(&keyed(rings(0.0, 0), &|_| {}), &key, (Some(0), Some(2)));
let turned = match &blend.shape {
Shape::Path { pair, .. } => pair.orient.1,
_ => panic!("a path blend"),
};
assert!((turned.seam() - 0.5).abs() < 1e-9, "both rings turn half way round: {turned:?}");
let s = stroke(&blend.at(1.0));
assert_eq!(s.dash.map(|d| d.offset), Some(0.75));
}
#[test]
fn gradients_of_other_stop_counts_keep_their_steps() {
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 };
let stepped = Gradient::new(
vectorcraft_color::GradientKind::Linear,
vec![
GradientStop::new(0.0, Color::WHITE),
GradientStop::new(0.5, Color::BLACK),
GradientStop::new(0.5, Color::WHITE),
GradientStop::new(0.50015, red),
GradientStop::new(1.0, Color::BLACK),
],
);
let (a, b) = common_stops(&stepped, &Gradient::default()).unwrap_or_else(|| panic!("one kind"));
let at = |g: &Gradient| g.stops.iter().map(|s| (s.offset, s.color)).collect::<Vec<_>>();
assert_eq!(at(&a), vec![(0.0, Color::WHITE), (0.5, Color::BLACK), (0.5, Color::WHITE), (0.50015, red), (1.0, Color::BLACK)]);
assert_eq!(b.stops.len(), 5);
assert_eq!(b.stops.get(1).map(|s| s.color), b.stops.get(2).map(|s| s.color), "a gradient without the step paints one color there");
}
#[test]
fn odd_dash_blends_to_solid_with_closed_gaps() {
let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap();
+3 -3
View File
@@ -10302,9 +10302,9 @@ How blends interpolate: closed shapes start where they twist least unless `start
interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale,
width profiles and the pen pressure, tilt, bearing and rotation recorded with the Paintbrush (a stroke without a
recording counts as half pressure, half tilt and 0°, as brushes read it; angles turn the shorter way round; a key's
profiles, recordings and arrowheads turn with its path when the blend starts it at another anchor), while
caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with
different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only
profiles, recordings, arrowheads, dashes and gradient along or across the stroke turn with its path when the blend
starts it at another anchor), while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one
kind with different stop counts resample to a common set of stops, keeping each step (two stops at one offset). Groups pair their members in stacking order (members only
one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and
images interpolate their transforms (type also its colours, stroke weight and size run by run; the words and the
symbol switch halfway). New blends are knockout groups (`object.setProps {knockout}` changes it), drawn on the
+1 -1
View File
@@ -1112,7 +1112,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512).
View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.
- **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html).
- **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`).
- **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile, arrowheads, dashes and a gradient along or across the stroke turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`, `stroke_gradients_and_dashes_turn_with_a_key_path_started_elsewhere`).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are.