From fc70b08f71da281fbdfa9502e2d29555bdd60a42 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:16:58 -0800 Subject: [PATCH] Blend: a blend without a spine shows the Appearance widget A blend made with Create Spine set to Never now shows the Appearance widget with the Selection and Blend tools, so its color Shift can be dragged on the canvas as on a blend with a spine. The Easing widget stays hidden, since easing needs a spine. The widget sits 16 px above the halfway point, by length, of the straight lines between the key centers that the steps are laid out along, and a drag follows those lines' direction there. The lines are not drawn. `blend_layout_line` exposes that line; `blend_spine` still returns `None` for such a blend. --- crates/doc/src/blend.rs | 30 +++++++++++ crates/doc/src/live.rs | 2 +- crates/engine/src/tests/blendopts.rs | 41 ++++++++++++++ crates/tools/src/blendwidget.rs | 78 +++++++++++++++++++++++---- crates/ui-egui/src/canvas/overlays.rs | 4 +- docs/gaps.md | 2 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 4 +- docs/ui-parity.md | 2 +- 9 files changed, 150 insertions(+), 18 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d9e179286f..ac06205d7a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1369,6 +1369,14 @@ pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Op layout_spine(keys, spec) } +/// The line the steps of a blend of `keys` are placed along: [`blend_spine`]'s path for a blend +/// with a spine, and the straight lines between the key centers, in key order, for a blend made +/// without one ([`BlendSpec::no_spine`]). For a blend without a spine the line places the steps +/// only; it is not a spine and is not drawn as one. `None` with fewer than two keys. +pub fn blend_layout_line(keys: &[Arc], spec: &BlendSpec) -> Option { + layout_spine(keys, spec).map(|(path, _)| path) +} + /// The spine `spec` stores, unless the blend was made without one. fn stored_spine(spec: &BlendSpec) -> Option<&PathData> { spec.spine.as_ref().filter(|_| !spec.no_spine) @@ -1840,6 +1848,28 @@ mod tests { assert_eq!(moved.subpaths[0].anchors[2].p, Point::new(305.0, 5.0)); } + #[test] + fn a_blend_without_a_spine_has_a_layout_line_through_its_key_centers() { + let at = + |x: f64, y: f64| Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x - 5.0, y - 5.0, x + 5.0, y + 5.0)), Appearance::default())); + let line_of = |path: &PathData| path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect::>(); + // A stored spine is ignored once the blend is made without one. + let stored = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(300.0, 50.0)], false)); + let spec = BlendSpec { no_spine: true, spine: Some(stored), ..Default::default() }; + for keys in [vec![at(10.0, 20.0), at(110.0, 20.0)], vec![at(10.0, 20.0), at(110.0, 20.0), at(110.0, 220.0), at(-40.0, 0.0)]] { + assert!(blend_spine(&keys, &spec).is_none(), "still no spine"); + let line = blend_layout_line(&keys, &spec).unwrap(); + assert_eq!(line.subpaths.len(), 1); + assert!(!line.subpaths[0].closed); + let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); + assert_eq!(line_of(&line), centers, "in key order"); + } + // With a spine, the layout line is the spine. + let keys = vec![key(0.0), key(100.0), key(300.0)]; + assert_eq!(blend_layout_line(&keys, &BlendSpec::default()), blend_spine(&keys, &BlendSpec::default()).map(|(p, _)| p)); + assert!(blend_layout_line(&keys[..1], &spec).is_none(), "one key"); + } + #[test] fn steps_follow_a_bent_spine() { let keys = vec![key(0.0), key(100.0)]; diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 39fe8cbc43..b1d94c1c8b 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -39,7 +39,7 @@ fn fifty() -> f64 { // ===================================================================================== pub use crate::blend::{ - Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, + Spine, blend_expand, blend_layout_line, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, steps_knockout_shows, }; diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 93f531d1e8..05fbf1a72e 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -542,6 +542,47 @@ fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() { assert_eq!(spec(&s, g).1.color_shift, 0.0); } +/// A blend made without a spine has the Appearance widget alone: a drag of it with the Selection +/// tool or the Blend tool changes the color shift in one undo step, and there is no Easing widget +/// to drag. +#[test] +fn dragging_the_appearance_widget_of_a_blend_without_a_spine_is_one_undo_step() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "createSpine": "never", "orientation": "path"})).unwrap()); + assert!(spec(&s, g).1.no_spine); + let v = ViewInfo::default(); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); + }; + for (n, tool) in [(1.0, "selection"), (2.0, "blend")] { + s.select_tool(tool, v).unwrap(); + sel(&mut s, &[g]); + let d0 = depth(&s); + // The Appearance widget sits 16 px above the halfway point (120, 20) of the line between + // the key centers. + ptr(&mut s, PointerKind::Down, 120.0, 4.0); + ptr(&mut s, PointerKind::Drag, 135.0, 4.0); + ptr(&mut s, PointerKind::Up, 135.0, 4.0); + let sp = spec(&s, g).1; + assert_eq!((sp.color_shift, sp.ease.ramp, depth(&s)), (15.0 * n, 0.0, d0 + 1), "{tool}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g], "{tool}: the blend stays selected"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.color_shift, 15.0, "each drag is its own undo step"); + // 16 px below there is no widget: a drag there leaves the easing and the color shift alone. + s.select_tool("selection", v).unwrap(); + sel(&mut s, &[g]); + let before = spec(&s, g).1; + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 150.0, 36.0); + ptr(&mut s, PointerKind::Up, 150.0, 36.0); + let after = spec(&s, g).1; + assert_eq!((after.ease, after.color_shift), (before.ease, before.color_shift), "no Easing widget"); +} + #[test] fn native_blend_easing_values_clamp_and_non_finite_become_default() { for (bad, ramp, shift) in [ diff --git a/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs index af1194d78b..e09df663bb 100644 --- a/crates/tools/src/blendwidget.rs +++ b/crates/tools/src/blendwidget.rs @@ -5,8 +5,11 @@ //! the canvas draws from the halfway point to the widget grows with it. The upper one sets the //! blend's color shift (`colorShift`, -100 to 100) and the lower one its easing strength (`ramp`, //! 0 to 100). The lower one shows only when the steps follow a spine (a replaced or edited spine, -//! or Align to Path), where easing moves them; a blend made without a spine shows neither. A drag along the spine's direction at the press -//! changes the value by one per screen pixel, starting from the value at the press, and the arrow +//! or Align to Path), where easing moves them. A blend made without a spine shows the upper one +//! alone, placed in the same way against the straight lines between its key centers that its steps +//! are laid out along ([`blend_layout_line`]); the canvas does not draw those lines. A drag along +//! the spine's direction at the press changes the value by one per screen pixel, starting from the +//! value at the press, and the arrow //! keys during the drag change it by one (Shift: by ten); Escape or a switch to another tool //! cancels the drag and puts the value back. Each drag previews //! `object.blend.setEasing` and is one undo step, or none when the value ends where it started; a @@ -14,7 +17,7 @@ //! from the same geometry. use serde_json::json; -use vectorcraft_doc::live::{Spine, blend_spine}; +use vectorcraft_doc::live::{Spine, blend_layout_line}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Point, Rect, Vec2}; @@ -69,7 +72,8 @@ impl BlendControl { pub struct BlendWidget { pub id: NodeId, pub control: BlendControl, - /// The spine's halfway point, in the document. + /// The spine's halfway point, in the document (for a blend without a spine, the halfway point of + /// the line its steps are laid out along). pub on: Point, /// The widget's center in the document: [`OFFSET_PX`] from `on`, or further out where other /// artwork crowds that spot. @@ -84,9 +88,10 @@ impl BlendWidget { /// The widgets of the selection at `zoom` (screen px per document point), with `up` the /// document direction of the screen's up: the Appearance widget, then the Easing widget. Empty /// unless the selection is one whole blend, visible and unlocked, with two or more keys and a - /// spine of some length, and no anchors are selected. Each widget sits at the first spot out - /// from the spine, in [`OFFSET_PX`] steps, that the boxes of fewer than three other visible - /// objects hold, at most nine steps out; where every spot is crowded it stays at the first. + /// spine (or, without one, a step layout line) of some length, and no anchors are selected. + /// Each widget sits at the first spot out from the spine, in [`OFFSET_PX`] steps, that the + /// boxes of fewer than three other visible objects hold, at most nine steps out; where every + /// spot is crowded it stays at the first. pub fn of(doc: &Document, selection: &Selection, zoom: f64, up: Vec2) -> Vec { let up_len = up.hypot(); let usable = |v: f64| v.is_finite() && v > 0.0; @@ -98,7 +103,7 @@ impl BlendWidget { if children.len() < 2 || !doc.is_editable(*id) { return vec![]; } - let Some(spine) = blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)) else { return vec![] }; + let Some(spine) = blend_layout_line(children, spec).and_then(|path| Spine::new(&path)) else { return vec![] }; if !usable(spine.length()) { return vec![]; } @@ -430,9 +435,60 @@ mod tests { // One key. let (d, _, s) = blend_doc(&[(100.0, 200.0)], eased()); assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "one key"); - // A blend made without a spine (Create Spine › Never), Align to Path included. - let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); - assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine"); + // Without a spine: locked or hidden, or keys on one spot. + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + for hide in [false, true] { + let mut d = d.clone(); + let n = d.node_mut(id).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine, hidden: {hide}"); + } + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine, no length"); + } + + /// A blend made without a spine (Create Spine › Never), Align to Path included, shows the + /// Appearance widget alone, at the halfway point by length of the straight lines between its + /// key centers. + #[test] + fn a_blend_without_a_spine_shows_the_appearance_widget_alone() { + let no_spine = || BlendSpec { no_spine: true, ..eased() }; + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert_eq!(w.iter().map(|w| w.control).collect::>(), [BlendControl::Appearance], "{w:?}"); + assert!(w[0].id == id && near(w[0].on, Point::new(200.0, 200.0)) && near(w[0].at, Point::new(200.0, 184.0)), "{w:?}"); + assert_eq!(w[0].value, -20.0); + // Halfway along by length, not the middle key: (0, 0), (100, 0), (100, 300) is 400 long. + let (d, _, s) = blend_doc(&[(0.0, 0.0), (100.0, 0.0), (100.0, 300.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(w.len() == 1 && near(w[0].on, Point::new(100.0, 100.0)), "{w:?}"); + // There the line runs down, so a drag down adds. + assert!(near(Point::new(w[0].along.x, w[0].along.y), Point::new(0.0, 1.0)), "{w:?}"); + // Two keys on one spot and a third away: the line still has length. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0), (300.0, 200.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(w.len() == 1 && near(w[0].on, Point::new(200.0, 200.0)), "{w:?}"); + } + + /// With the Selection or the Blend tool, a drag of a spineless blend's Appearance widget + /// previews the color shift alone, in one undo step. + #[test] + fn dragging_the_appearance_widget_of_a_blend_without_a_spine() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 184.0); + assert!(over_widget(&c, at) && !over_widget(&c, Point::new(200.0, 216.0)), "no Easing widget below"); + let tools: [Box; 2] = [Box::new(SelectionTool::default()), Box::new(crate::meshblend::BlendTool::default())]; + for mut t in tools { + assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(30.0, 0.0))); + assert_eq!( + a, + vec![Action::Begin(LABEL.into()), Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": 10.0}))] + ); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at + Vec2::new(30.0, 0.0))), vec![Action::Commit]); + } } /// A drag of the Easing widget along the spine changes the ramp by one per pixel from its diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 7eb31f7d01..aefd9253e9 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -252,7 +252,9 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: /// The Appearance and Easing widgets of one selected blend (Selection and Blend tools), each joined /// by a line to the spine's halfway point: the Appearance widget hollow above it, the Easing widget -/// filled below it. Drawn with the edges hidden too, as they are controls. +/// filled below it. For a blend without a spine the line goes to the halfway point of the straight +/// lines between its key centers, which are not drawn. Drawn with the edges hidden too, as they are +/// controls. pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { use vectorcraft_tools::blendwidget::{BlendControl, BlendWidget, RADIUS_PX}; if !matches!(app.session.tool_id(), "selection" | "blend") { diff --git a/docs/gaps.md b/docs/gaps.md index d34956d4e0..4c492fb5b3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -541,7 +541,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. The original polynomial distributions approximate the public description. With the Selection or Blend tool, on-canvas widgets on a blend with a spine set the color Shift and, when the steps follow - that spine, the Ramp; a blend made without a spine shows neither widget. The Blend panel shows the + that spine, the Ramp; a blend made without a spine shows the color Shift widget alone. The Blend panel shows the values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..14d6838506 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9769,8 +9769,9 @@ the Selection and Blend tools show the same values as widgets 16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets `colorShift` and the lower one, shown only when the steps follow the spine as `ramp` needs, sets `ramp`, by one per screen pixel dragged along the spine and by one per arrow key -during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool -during it puts the value back: +during the drag (ten with Shift). A blend made without a spine shows the upper widget alone, placed against the +straight lines between its key centers that its steps are laid out along, which are not drawn. Each drag is one undo +step, and `press_key` Escape or a switch to another tool during it puts the value back: ```json {"name":"run_command","arguments":{"command":"object.blend.setEasing","params":{"id":40,"ramp":35}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..223dbc591a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1780,7 +1780,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. -- **Blend widgets on the canvas:** with one blend selected that has a spine, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow that spine; a blend made without a spine shows neither. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. +- **Blend widgets on the canvas:** with one blend selected that has a spine, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow that spine. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. + +- **Blend widgets without a spine:** a blend made with Create Spine set to Never shows the Appearance widget alone with the Selection and Blend tools, and its drag sets Shift as on a blend with a spine; there is no Easing widget, as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) makes Easing available only on a blend with a spine. The widget sits 16 px above the halfway point, by length, of the straight lines between the key centers that the steps are laid out along, and a drag follows those lines' direction there; the lines are not drawn. Showing the widget without a spine and its placement are VectorCraft's own rules. Evidence: `blendwidget::tests::{a_blend_without_a_spine_shows_the_appearance_widget_alone, dragging_the_appearance_widget_of_a_blend_without_a_spine}`, `tests::blendopts::dragging_the_appearance_widget_of_a_blend_without_a_spine_is_one_undo_step`, `blend::tests::a_blend_without_a_spine_has_a_layout_line_through_its_key_centers`. - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5391e3dc5b..2850f60ca4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -436,7 +436,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Canvas context menu | done | Layers rows, panels and type have none (gap) | | SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` | | Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | -| Blend panel (30.7) | partial | Window › Blend provides Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift, alongside the existing blend controls. Easing is disabled without a spine. Original polynomial distributions approximate the publicly documented behavior; on-canvas easing handles remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) | +| Blend panel (30.7) | partial | Window › Blend provides Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift, alongside the existing blend controls. Easing is disabled without a spine. Original polynomial distributions approximate the publicly documented behavior. On-canvas Appearance and Easing widgets set Shift and Ramp; a blend without a spine shows the Appearance widget alone. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) | | Gradient panel: Dither (30.0) | done | Dither box under the stops (`paint.editGradient` `dither`); dimmed behind the Stroke proxy, fills only; `panels::gradient` test | | Gradient panel: Method, Classic or Perceptual (30.0) | done | Method menu under the Stroke buttons for linear and radial gradients (`paint.editGradient` `method`); [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html); `panels::gradient` test | | Gradient panel: Presets (30.0) | done | Presets menu by library (theme) plus Saved: applies a preset to the gradient behind the active proxy, keeping its placement, one undo step; panel menu Save Gradient Preset…, Delete Gradient Preset, Hide/Show Presets (`gradient.presets.*`); `panels::gradient` tests |