Blend: choosing the Blend tool again finishes the blend

Squashed from #4454 onto main.
This commit is contained in:
dotCooCoo
2026-10-29 18:50:34 +07:00
committed by bflatastic
parent 6fd042e4be
commit 74b408e8da
9 changed files with 292 additions and 1 deletions
+49
View File
@@ -119,6 +119,55 @@ fn a_blend_and_an_object_make_one_blend_with_more_keys() {
assert_eq!(vectorcraft_doc::live::expand_live(&n).len(), 3 + 2 * 3);
}
/// Choosing the Blend tool again (its button, W, `tool.select`) finishes the blend: the first
/// pick and the chain are dropped, and the document and the selection stay. Choosing another
/// tool again keeps its work: the Pen's open path stays open.
#[test]
fn choosing_the_blend_tool_again_finishes_the_blend() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
let v = ViewInfo::default();
s.select_tool("blend", v).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
click(&mut s, 20.0, 20.0);
assert!(s.tool_busy());
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
s.select_tool("blend", v).unwrap();
assert!(!s.tool_busy(), "choosing the tool again drops the pick");
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "and changes nothing");
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// The next two clicks blend b and c; a is not part of it.
click(&mut s, 220.0, 20.0);
assert_eq!(s.doc().unwrap().doc.layers, layers, "b is picked first");
click(&mut s, 420.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g).0, vec![b, c]);
// Chosen again after the blend is made: the next click starts a new blend instead of adding.
s.select_tool("blend", v).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected");
click(&mut s, 20.0, 20.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(spec(&s, g).0, vec![b, c], "a was not added");
// The Pen chosen again goes on drawing its open path.
s.select_tool("pen", v).unwrap();
s.execute("select.none", &json!({})).unwrap();
let pen = |s: &mut Session, x: f64, y: f64| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
}
};
pen(&mut s, 100.0, 300.0);
pen(&mut s, 200.0, 300.0);
let p = s.doc().unwrap().selection.objects[0];
s.select_tool("pen", v).unwrap();
pen(&mut s, 300.0, 300.0);
let anchors = match node(&s, p).kind {
NodeKind::Path { path, .. } => path.subpaths[0].anchors.len(),
_ => 0,
};
assert_eq!(anchors, 3, "the Pen keeps drawing the same path");
}
#[test]
fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() {
let mut s = session();
+9 -1
View File
@@ -106,7 +106,15 @@ impl Session {
{
crate::cmd::distortcmds::silent(self, |g| g.visible = true)?;
}
self.switch_tool(id, view)
// When the tool already active, or the tool lent a selection tool for a drag, is chosen again,
// `select_tool` calls its [`Tool::chosen_again`] (the Blend tool finishes its blend).
let again = self.lender.as_ref().unwrap_or(&self.tool).id() == id;
self.switch_tool(id, view)?;
if again {
let acts = self.with_tool_cx(view, |t, cx| t.chosen_again(cx));
self.apply_actions(acts)?;
}
Ok(())
}
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
+5
View File
@@ -219,6 +219,11 @@ pub trait Tool: Send {
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
}
/// The tool, already active, is chosen again (its toolbar button, its shortcut, `tool.select`).
/// Most tools keep their state; the Blend tool finishes the blend it is adding to.
fn chosen_again(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
}
/// A command from outside the tool ran (a menu, a panel, an agent): finish work it ended (the
/// Type tool stops editing text the command took out of the selection).
fn after_command(&mut self, _cx: &ToolContext) -> Vec<Action> {
+45
View File
@@ -148,6 +148,12 @@ impl Tool for BlendTool {
(self.first, self.chained) = (None, false);
vec![]
}
/// Choosing the Blend tool again finishes the blend: it drops the object picked first and
/// stops adding to the blend just made, so the next click picks a new first object. The
/// document and the selection stay as they are.
fn chosen_again(&mut self, cx: &ToolContext) -> Vec<Action> {
self.deactivate(cx)
}
}
#[derive(Default)]
@@ -296,6 +302,45 @@ mod tests {
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]);
}
/// Choosing the Blend tool again drops the object picked first and stops adding to the blend
/// just made: the next click is a new first pick. The Mesh tool keeps its state.
#[test]
fn choosing_the_blend_tool_again_finishes_the_blend() {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let (b, g) = (d.alloc_id(), d.alloc_id());
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
let (k1, k2) = (d.alloc_id(), d.alloc_id());
let key = |id: NodeId, x: f64| {
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
};
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
d.insert(Some(l), 2, blend).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let picks = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
let down = |x: f64, y: f64| PointerEvent::new(PointerKind::Down, x, y);
let mut t = BlendTool::default();
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
assert!(t.busy());
assert!(t.chosen_again(&c).is_empty(), "choosing the tool again changes nothing in the document");
assert!(!t.busy() && t.first(&c).is_none() && t.overlays(&c).is_empty(), "and drops the pick");
assert_eq!(t.pointer(&c, &down(320.0, 120.0)), picks(b), "b is picked first");
// After a blend is made, the next click no longer adds to it.
let mut s = Selection::default();
s.set([g]);
let c = cx(&d, &s, &p);
let mut t = BlendTool { first: None, chained: true };
assert!(t.first(&c).is_some());
assert!(t.chosen_again(&c).is_empty());
assert!(t.first(&c).is_none(), "choosing the tool again ends adding to the blend just made");
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
// Other tools keep their state: the default does nothing.
assert!(create("mesh").unwrap().chosen_again(&c).is_empty());
}
#[test]
fn blend_tool_anchor_clicks_and_options() {
let (mut d, a) = doc_with_rect();
+69
View File
@@ -547,6 +547,26 @@ fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo)
let _ = app.session.tool_key(ToolKey::Letter(c), Mods::default(), view);
}
}
// Between clicks, the active tool's shortcut chooses that tool again (W with the Blend
// tool's first pick held finishes the blend). While the button is held, it does nothing.
if !app.session.pointer_held() {
let presses = ctx.input(|i| shortcut_presses(&i.events, i.modifiers));
let keymap = crate::shortcut_editor::current();
let active = app.session.tool_id();
let again = presses.iter().any(|(names, letter)| {
let claimed = letter.is_some_and(|c| app.session.tool_claims_key(ToolKey::Letter(c), view));
// The first name bound to anything is the press's shortcut (Shift+W is the Width
// tool's, not W's).
!claimed
&& names
.iter()
.find_map(|name| crate::keymap::vc_chord(name).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)))
.is_some_and(|c| c.id.strip_prefix("tool:") == Some(active))
});
if again {
app.select_tool(active);
}
}
}
/// Give keys claimed by the active tool precedence over their command shortcuts.
@@ -705,6 +725,55 @@ fn latin(text: &str, physical: Option<Key>) -> String {
}
}
/// Each new press's shortcut names, first match first, and the letter it types, without Cmd, Ctrl
/// or Alt. A text-only press counts as well (Shift+~ arrives as the text `~` with Shift), unless
/// the key that typed it repeated. A text's key is the press right before it (egui-winit) or,
/// failing that, right after it (eframe's web build with no text field focused). Text reads its
/// key's modifiers, or the latest modifier change (`held` before any), and tries its key's own
/// name first.
fn shortcut_presses(events: &[egui::Event], held: Modifiers) -> Vec<(Vec<String>, Option<char>)> {
let key_name = |key: &Key, m: &Modifiers| if m.shift { format!("Shift+{}", key.name()) } else { key.name().to_string() };
let press_at = |j: Option<usize>| match j.and_then(|j| events.get(j)) {
Some(egui::Event::Key { key, pressed: true, repeat, modifiers, .. }) => Some((*repeat, *modifiers, key_name(key, modifiers))),
_ => None,
};
let mut latest = held;
events
.iter()
.enumerate()
.filter_map(|(j, e)| match e {
egui::Event::ModifiersChanged(m) => {
latest = *m;
None
}
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !(modifiers.command || modifiers.alt || modifiers.ctrl) => {
Some((vec![key_name(key, modifiers)], letter_of(*key)))
}
egui::Event::Text(t) => {
let typed_by = press_at(j.checked_sub(1)).or_else(|| press_at(Some(j + 1)));
let (repeated, mods, key_name) = typed_by.map_or((false, latest, None), |(r, m, n)| (r, m, Some(n)));
if repeated || mods.command || mods.alt || mods.ctrl {
return None;
}
let upper = t.to_uppercase();
let alpha = t.chars().all(|c| c.is_alphabetic());
// A shifted letter is only Shift+letter; other shifted text may also be written
// without its Shift.
let text_names = match (mods.shift, alpha) {
(true, true) => vec![format!("Shift+{upper}")],
(true, false) => vec![upper, t.clone(), format!("Shift+{t}")],
(false, _) => vec![upper, t.clone()],
};
let names: Vec<String> = key_name.into_iter().chain(text_names).collect();
let mut chars = t.chars();
let letter = chars.next().filter(|c| c.is_ascii_alphabetic() && chars.next().is_none()).map(|c| c.to_ascii_lowercase());
Some((names, letter))
}
_ => None,
})
.collect()
}
/// The digit a number-row or keypad key types.
fn digit_of(k: Key) -> Option<u8> {
const DIGITS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
+82
View File
@@ -1094,6 +1094,88 @@ fn e_reaches_a_dragged_marquee_once_per_press() {
assert_eq!(letter_of(Key::Escape), None);
}
/// W, the Blend tool's shortcut, pressed while the Blend tool holds a first pick finishes the
/// blend: the tool stays chosen, and the next click picks a new first object. Another tool's
/// shortcut does nothing while the pick is held.
#[test]
fn the_blend_tool_shortcut_finishes_the_blend() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
for x in [0, 200] {
app.session.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap();
}
app.select_tool("blend");
let click = |app: &mut VectorcraftApp, x: f64| {
let v = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, x, 20.0), v).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Up, x, 20.0), v).unwrap();
};
let press = |key| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE };
click(&mut app, 20.0);
assert!(app.session.tool_busy(), "the first rectangle is picked");
let layers = app.session.active().unwrap().doc.layers.clone();
frame(&mut app, vec![press(Key::P), egui::Event::Text("p".into())]);
assert_eq!(app.session.tool_id(), "blend", "the Pen's shortcut does nothing while the pick is held");
assert!(app.session.tool_busy());
frame(&mut app, vec![press(Key::W), egui::Event::Text("w".into())]);
assert_eq!(app.session.tool_id(), "blend");
assert!(!app.session.tool_busy(), "W drops the pick");
click(&mut app, 220.0);
assert!(app.session.tool_busy(), "the second rectangle is a new first pick");
assert_eq!(app.session.active().unwrap().doc.layers, layers, "no blend was made");
}
/// A Blend tool shortcut that arrives only as text (Shift+~ types `~`) also finishes the blend,
/// while another tool's shifted shortcut does not.
#[test]
fn a_text_only_blend_shortcut_finishes_the_blend() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:curvature", "");
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:blend", "Shift+~");
app.session.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap();
app.select_tool("blend");
let v = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, 20.0, 20.0), v).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Up, 20.0, 20.0), v).unwrap();
assert!(app.session.tool_busy(), "the rectangle is picked");
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("W".into())]);
assert!(app.session.tool_busy(), "Shift+W is another tool's shortcut and keeps the pick");
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:width", "");
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("W".into())]);
assert!(app.session.tool_busy(), "an unbound Shift+W is not the Blend tool's W");
let shift_w = egui::Event::Key { key: Key::W, physical_key: Some(Key::W), pressed: true, repeat: false, modifiers: Modifiers::SHIFT };
let release = egui::Event::ModifiersChanged(Modifiers::NONE);
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), shift_w, release, egui::Event::Text("W".into())]);
assert!(app.session.tool_busy(), "Shift released after Shift+W in the same frame keeps the pick");
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:blend", "=");
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:pen", "Shift+=");
let shift_eq = egui::Event::Key { key: Key::Equals, physical_key: Some(Key::Equals), pressed: true, repeat: false, modifiers: Modifiers::SHIFT };
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), shift_eq, egui::Event::Text("+".into())]);
assert!(app.session.tool_busy(), "Shift+= is the Pen's shortcut, not the Blend tool's =");
crate::keymap::set_override(&mut app.ui.shortcut_overrides, "tool:blend", "Shift+~");
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("~".into())]);
assert_eq!(app.session.tool_id(), "blend");
assert!(!app.session.tool_busy(), "Shift+~ drops the pick");
}
/// A repeated key's text is no new press, whether it comes after its key or before it.
#[test]
fn a_repeated_keys_text_is_no_shortcut_press() {
let key = |repeat| egui::Event::Key { key: Key::Backtick, physical_key: Some(Key::Backtick), pressed: true, repeat, modifiers: Modifiers::SHIFT };
let text = || egui::Event::Text("~".into());
assert!(shortcut_presses(&[key(true), text()], Modifiers::SHIFT).is_empty());
assert!(shortcut_presses(&[text(), key(true)], Modifiers::SHIFT).is_empty());
let fresh = shortcut_presses(&[key(false), text()], Modifiers::SHIFT);
assert_eq!(fresh.len(), 2);
assert_eq!(fresh[1].0.first().map(String::as_str), Some("Shift+Backtick"), "the text tries its key's name first");
assert!(fresh[1].0.iter().any(|n| n == "Shift+~"));
let only_text = shortcut_presses(&[text()], Modifiers::SHIFT);
assert_eq!(only_text.len(), 1);
}
/// Shortcuts reach tools, panels (`window.panel {panel}`) and commands through the keymap,
/// and a held Cmd+= keeps zooming in.
#[test]
+28
View File
@@ -177,6 +177,34 @@ fn double_clicking_a_tool_button_opens_its_options() {
assert!(app.run("tool.options", json!({"tool": "lasso"})).is_err());
}
/// A click on the Blend tool's button while the Blend tool holds a first pick finishes the
/// blend: the tool stays chosen, and the next click on the canvas picks a new first object
/// instead of blending with the first.
#[test]
fn clicking_the_active_blend_tool_button_finishes_the_blend() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.ui.toolbar_advanced = true;
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
for x in [0, 200] {
app.run("shape.rectangle", json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap();
}
app.select_tool("blend");
let index = slots(&app).iter().position(|(_, tools)| tools.contains(&"blend")).expect("a toolbar slot");
let ctx = egui::Context::default();
let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center();
let down = |x, y| vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, x, y);
let view = app.view_info();
app.session.pointer(&down(20.0, 20.0), view).unwrap();
assert!(app.session.tool_busy(), "the first rectangle is picked");
let layers = app.session.active().unwrap().doc.layers.clone();
click_in(&mut app, &ctx, 1.0, at, PointerButton::Primary, 2000.0);
assert_eq!(app.session.tool_id(), "blend");
assert!(!app.session.tool_busy(), "the click on the button drops the pick");
app.session.pointer(&down(220.0, 20.0), view).unwrap();
assert!(app.session.tool_busy(), "the second rectangle is a new first pick");
assert_eq!(app.session.active().unwrap().doc.layers, layers, "no blend was made");
}
/// A double-click on the Type on a Path or Vertical Type on a Path tool opens Type on a Path
/// Options for the selected type on a path, as the menu item does; with nothing selected, or
/// with no type on a path selected, nothing opens.
+4
View File
@@ -5355,6 +5355,10 @@ among the objects takes the others in as more key objects, keeping its options,
{"name":"run_command","arguments":{"command":"object.blend.info","params":{}}}
```
With the Blend tool active, `tool.select {tool: "blend"}` finishes the blend, as a click on the tool's button or W
does: it drops the object clicked first and stops the next click adding to the blend just made. The document and the
selection stay as they are. Choosing any other tool that is already active leaves its work as it is.
A blend's spine is the straight lines between its key centres until it is edited. `object.blend.info` lists it
(`spine.anchors`, `keyAnchors`: the point each key sits on). `object.blend.spine.addAnchor {x, y}` adds a point where
the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a point (a key on it moves along; with
+1
View File
@@ -855,6 +855,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756).
Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)).
Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`.
Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)).
The four polynomial distributions are original approximations of the public description, not measured