From 77f39084deb33dfc3cba009b80562ba7e25f19d3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:44:23 -0700 Subject: [PATCH] =?UTF-8?q?Blend:=20Window=20=E2=80=BA=20Blend=20opens=20a?= =?UTF-8?q?=20Blend=20panel=20with=20the=20blend's=20spacing,=20orientatio?= =?UTF-8?q?n=20and=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference app has a Blend panel, opened with Window > Blend, that makes and edits blends in one place. VectorCraft had the Blend tool, Object › Blend and the Blend Options dialog, but no Blend panel. Window › Blend opens a panel with Make, the selected blend's Spacing and Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release. Each control runs its Object › Blend command, so a change of the selected blend's spacing or orientation shows on the canvas at once and is one undo step. With two or more objects selected and no blend, Spacing and Orientation set the options new blends start with, as Blend Options does. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend Options dialog's orientation toggles now have names for screen readers. Docs: docs/target-app-parity.md, docs/ui-parity.md. --- crates/ui-egui/src/dialogs/blend_options.rs | 8 +- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/blend.rs | 112 ++++++++++ crates/ui-egui/src/panels/mod.rs | 4 + crates/ui-egui/src/panels/tests_blend.rs | 227 ++++++++++++++++++++ crates/ui-egui/src/state.rs | 1 + docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 8 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/panels/blend.rs create mode 100644 crates/ui-egui/src/panels/tests_blend.rs diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs index 0ee43fc4c..4e139ff53 100644 --- a/crates/ui-egui/src/dialogs/blend_options.rs +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -25,7 +25,7 @@ const CMD: &str = "object.blend.options"; const LABEL_W: f32 = 82.0; /// Spacing modes: (value, label). -const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")]; +pub(crate) const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")]; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Blend Options").into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM }; @@ -100,8 +100,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } /// An orientation toggle drawn in code: three bars on a curve, upright (Align to Page) or square -/// to the curve (Align to Path), in a pressed well when `selected`. Returns clicked. -fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) -> bool { +/// to the curve (Align to Path), in a pressed well when `selected`. Screen readers name it `tip`. +/// Returns clicked. +pub(crate) fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) -> bool { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::click()); let bg = if selected { @@ -124,6 +125,7 @@ fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) let n = if along { vec2(-12.0 * x / 9.0, -1.0).normalized() } else { vec2(0.0, -1.0) }; ui.painter().line_segment([p, p + n * 7.0], Stroke::new(2.0, color)); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, tip)); resp.on_hover_text(tip).clicked() } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7d32a066..68325a916 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2842,6 +2842,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Artboards", "artboards"), panel("Asset Export", crate::panels::asset_export::ID), panel("Attributes", "attributes"), + panel("Blend", crate::panels::blend::ID), panel("Brushes", "brushes"), panel("Color", "color"), panel("Color Guide", "colorGuide"), diff --git a/crates/ui-egui/src/panels/blend.rs b/crates/ui-egui/src/panels/blend.rs new file mode 100644 index 000000000..5b723519e --- /dev/null +++ b/crates/ui-egui/src/panels/blend.rs @@ -0,0 +1,112 @@ +//! Blend panel (Window → Blend): Make, the Spacing and Orientation of the selected blend (with no +//! blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, +//! Replace Spine, Expand and Release. Each control runs its Object → Blend command +//! (`object.blend.make`, `object.blend.options` and the others), so a change of the selected +//! blend's spacing or orientation shows on the canvas at once as one undo step. Make is dimmed +//! until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two +//! or more objects are selected, and the other buttons are dimmed until a blend is selected. + +use egui::Ui; +use serde_json::{Value, json}; + +use super::selection_len; +use crate::VectorcraftApp; +use crate::dialogs::blend_options::{SPACING, orientation_button}; +use crate::widgets; + +pub const ID: &str = "blend"; + +/// Width of the label column, as in Blend Options. +const LABEL_W: f32 = 82.0; + +/// Width of the Specified Steps and Specified Distance field. +const FIELD_W: f32 = 72.0; + +/// A row of flat buttons sharing the row's width, each (label, command): a click runs the command, +/// and a button whose command is unavailable is dimmed. +fn buttons(app: &mut VectorcraftApp, ui: &mut Ui, row: &[(&str, &str)]) { + let gap = ui.spacing().item_spacing.x; + let n = row.len() as f32; + let width = ((ui.available_width() - gap * (n - 1.0)) / n).max(48.0); + ui.horizontal(|ui| { + for &(label, cmd) in row { + let on = available(app, cmd); + if ui.add_enabled_ui(on, |ui| widgets::flat_button(ui, label, width)).inner.clicked() { + app.run(cmd, json!({})).ok(); + } + } + }); +} + +/// Can `cmd` run on the selection? Make needs two or more objects, and the other commands need a +/// selected blend. +fn available(app: &VectorcraftApp, cmd: &str) -> bool { + crate::menus::enabled(app, cmd) && (cmd != "object.blend.make" || selection_len(app) >= 2) +} + +/// `object.blend.options` parameters for spacing mode `mode`, with the step count and distance +/// the panel shows. +fn spacing_params(mode: &str, steps: f64, distance: f64) -> Value { + match mode { + "steps" => json!({"spacing": "steps", "steps": steps}), + "distance" => json!({"spacing": "distance", "distance": distance}), + _ => json!({"spacing": "smooth"}), + } +} + +/// Spacing and Orientation, as `object.blend.info` reports them; a change runs +/// `object.blend.options`. +fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { + let info = app.session.execute("object.blend.info", &json!({})).unwrap_or_default(); + let unit = app.session.general_unit(); + let mode = info["spacing"].as_str().unwrap_or("smooth"); + let steps = info["steps"].as_f64().unwrap_or(5.0); + let distance = info["distance"].as_f64().unwrap_or(10.0); + let path = info["orientation"] == "path"; + let mut change = None; + let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l); + let labels = SPACING.map(|(_, l)| l); + widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| { + let width = ui.available_width().max(FIELD_W); + if let Some((value, _)) = widgets::dropdown(ui, "blend-panel-spacing", shown, &labels, width).and_then(|i| SPACING.get(i)) { + change = Some(spacing_params(value, steps, distance)); + } + }); + ui.horizontal(|ui| { + ui.add_space(LABEL_W); + if mode == "distance" { + if let Some(d) = widgets::num_field(ui, "blend-panel-distance", Some(distance), unit, FIELD_W) { + change = Some(spacing_params("distance", steps, d)); + } + } else { + // Smooth Color picks the count itself: the field shows the steps, disabled. + ui.add_enabled_ui(mode == "steps", |ui| { + if let Some(n) = widgets::range_field(ui, "blend-panel-steps", steps, 1.0..=1000.0, "", 0, FIELD_W) { + change = Some(spacing_params("steps", n, distance)); + } + }); + } + }); + widgets::label_row(ui, tl!("Orientation:"), LABEL_W, |ui| { + for (value, along, tip) in [("page", false, tl!("Align to Page")), ("path", true, tl!("Align to Path"))] { + if orientation_button(ui, along, path == along, tip) { + change = Some(json!({ "orientation": value })); + } + } + }); + if let Some(p) = change { + app.run("object.blend.options", p).ok(); + } +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let blend = crate::menus::enabled(app, "object.blend.release"); + buttons(app, ui, &[(tl!("Make"), "object.blend.make")]); + widgets::divider(ui); + ui.add_enabled_ui(blend || selection_len(app) >= 2, |ui| settings(app, ui)); + widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| buttons(app, ui, &[(tl!("Reverse Front to Back"), "object.blend.reverseFrontToBack")])); + widgets::divider(ui); + buttons(app, ui, &[(tl!("Reverse Spine"), "object.blend.reverseSpine"), (tl!("Replace Spine"), "object.blend.replaceSpine")]); + widgets::divider(ui); + buttons(app, ui, &[(tl!("Expand"), "object.blend.expand"), (tl!("Release"), "object.blend.release")]); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 984b8385c..d3b639e00 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,6 +9,7 @@ pub mod appearance; pub mod artboards; pub mod asset_export; pub mod attributes; +pub mod blend; pub mod brushes; pub mod character; pub mod color; @@ -146,6 +147,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "graphicStyles" => graphic_styles::show(app, ui), "align" => align::show(app, ui), "pathfinder" => pathfinder::show(app, ui), + blend::ID => blend::show(app, ui), "transform" => transform::show(app, ui), "history" => history::show(app, ui), "actions" => actions::show(app, ui), @@ -541,6 +543,8 @@ mod tests_appearance; #[cfg(test)] mod tests_asset_export; #[cfg(test)] +mod tests_blend; +#[cfg(test)] mod tests_constrain; #[cfg(test)] mod tests_css_properties; diff --git a/crates/ui-egui/src/panels/tests_blend.rs b/crates/ui-egui/src/panels/tests_blend.rs new file mode 100644 index 000000000..d2b15f7d9 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_blend.rs @@ -0,0 +1,227 @@ +//! The Blend panel drawn headless: Window › Blend opens it, and its controls do nothing until the +//! selection allows them. Make needs two or more objects, Spacing and Orientation need a blend or +//! two or more objects, and the other buttons need a blend. Make blends the selection, Spacing and +//! Orientation edit the selected blend through `object.blend.options`, and each other button runs +//! its Object › Blend command. + +use egui::{Event, Pos2, Rect, pos2, vec2}; +use serde_json::json; +use vectorcraft_doc::live::{BlendOrientation, BlendSpacing}; +use vectorcraft_doc::{BlendSpec, NodeId, NodeKind}; +use vectorcraft_engine::Session; + +use super::blend::{ID, show}; +use crate::{VectorcraftApp, theme}; + +/// Frames of the panel alone in a 300 × 600 window, with the accessibility tree on. +struct Frames { + ctx: egui::Context, + time: f64, +} + +impl Frames { + fn new() -> Self { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + Self { ctx, time: 0.0 } + } + + fn run(&mut self, app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { + self.time += 0.1; + let screen = Rect::from_min_size(Pos2::ZERO, vec2(300.0, 600.0)); + let input = egui::RawInput { events, time: Some(self.time), screen_rect: Some(screen), ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + out + } + + /// The center of the text `label` the panel draws. + fn text(&mut self, app: &mut VectorcraftApp, label: &str) -> Pos2 { + self.run(app, vec![]); + let out = self.run(app, vec![]); + let mut texts = vec![]; + fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")).1.center() + } + + /// The center of the button that screen readers name `label`. + fn button(&mut self, app: &mut VectorcraftApp, label: &str) -> Pos2 { + self.run(app, vec![]); + let out = self.run(app, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button); + let b = node.unwrap_or_else(|| panic!("no button named {label:?}")).1.bounds().expect("bounds"); + pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5) + } + + /// Move to `at` and click there. egui finds what the pointer is over from the frame before, so + /// the move gets a frame of its own, drawn with the selection as it is now. + fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) { + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + self.run(app, vec![Event::PointerMoved(at)]); + self.run(app, vec![button(true)]); + self.run(app, vec![button(false)]); + self.run(app, vec![]); + } + + /// Click the text `label` the panel draws. + fn click_text(&mut self, app: &mut VectorcraftApp, label: &str) { + let at = self.text(app, label); + self.click(app, at); + } +} + +/// A document with two 20 pt squares 200 pt apart, both selected. +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app +} + +/// [`app`] with the squares blended at Smooth Color; the blend is selected. +fn blended() -> (VectorcraftApp, NodeId) { + let mut app = app(); + let id = app.run("object.blend.make", json!({"smooth": true})).unwrap()["id"].as_u64().unwrap(); + (app, NodeId(id)) +} + +fn spec(app: &VectorcraftApp, id: NodeId) -> BlendSpec { + match &app.session.active().unwrap().doc.node(id).unwrap().kind { + NodeKind::Blend { spec, .. } => (**spec).clone(), + k => panic!("not a blend: {k:?}"), + } +} + +fn undo_depth(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// The top-level objects of the first layer. +fn top(app: &VectorcraftApp) -> Vec<(NodeId, bool)> { + let doc = &app.session.active().unwrap().doc; + doc.layers[0].children().unwrap().iter().map(|n| (n.id, matches!(n.kind, NodeKind::Blend { .. }))).collect() +} + +#[test] +fn window_blend_opens_the_panel() { + let mut app = app(); + let entries = crate::menus::menu_entries(&app); + let item = entries.iter().find(|e| e.path == ["Window"] && e.label == "Blend").expect("Window › Blend"); + assert_eq!(item.command.as_deref(), Some("window.panel")); + assert_eq!(item.params, json!({"panel": ID, "toggle": true})); + crate::menus::invoke(&mut app, "window.panel", item.params.clone()); + assert_eq!(app.ui.open_panel.as_deref(), Some(ID)); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show_icon_panel(app, ui, ID)); + for label in ["Make", "Spacing:", "Orientation:", "Direction:", "Reverse Front to Back", "Reverse Spine", "Replace Spine", "Expand", "Release"] { + assert!(text.contains(label), "{label} in {text}"); + } + assert!(!text.contains("on the roadmap"), "{text}"); +} + +#[test] +fn the_controls_are_dimmed_until_a_blend_or_two_objects_are_selected() { + let mut app = app(); + app.run("select.none", json!({})).unwrap(); + let depth = undo_depth(&app); + let mut f = Frames::new(); + let page = f.button(&mut app, "Align to Page"); + let path = f.button(&mut app, "Align to Path"); + // Nothing selected: no button acts and Orientation does not change the options new blends + // start with. + for label in ["Make", "Reverse Front to Back", "Reverse Spine", "Replace Spine", "Expand", "Release"] { + f.click_text(&mut app, label); + } + f.click(&mut app, path); + assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()["orientation"], "page"); + assert_eq!((top(&app).len(), undo_depth(&app)), (2, depth), "nothing ran"); + assert_eq!(app.ui.status, "", "no command ran to fail"); + // One object: Make stays dimmed. + let first = top(&app)[0].0; + app.run("select.set", json!({"ids": [first.0]})).unwrap(); + f.click_text(&mut app, "Make"); + assert_eq!((top(&app).len(), undo_depth(&app), app.ui.status.as_str()), (2, depth, ""), "Make did not run on one object"); + // Two objects: Orientation sets the options Make then uses. + app.run("select.all", json!({})).unwrap(); + f.click(&mut app, path); + assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()["orientation"], "path"); + f.click(&mut app, page); + assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()["orientation"], "page"); +} + +#[test] +fn make_blends_the_selected_objects_in_one_undo_step() { + let mut app = app(); + let depth = undo_depth(&app); + let mut f = Frames::new(); + f.click_text(&mut app, "Make"); + let objects = top(&app); + assert_eq!(objects.len(), 1, "{objects:?}"); + assert!(objects[0].1, "a blend"); + assert_eq!(undo_depth(&app), depth + 1); +} + +#[test] +fn spacing_and_orientation_edit_the_selected_blend() { + let (mut app, id) = blended(); + let mut f = Frames::new(); + // The dropdown shows the blend's Smooth Color; Specified Steps gives it the steps shown. + f.click_text(&mut app, "Smooth Color"); + let depth = undo_depth(&app); + f.click_text(&mut app, "Specified Steps"); + assert_eq!(spec(&app, id).spacing, BlendSpacing::Steps(5)); + assert_eq!(undo_depth(&app), depth + 1, "one undo step"); + f.click_text(&mut app, "Specified Steps"); + f.click_text(&mut app, "Specified Distance"); + assert_eq!(spec(&app, id).spacing, BlendSpacing::Distance(10.0)); + let path = f.button(&mut app, "Align to Path"); + f.click(&mut app, path); + assert_eq!(spec(&app, id).orientation, BlendOrientation::AlignToPath); + assert_eq!(spec(&app, id).spacing, BlendSpacing::Distance(10.0), "Orientation keeps the spacing"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(spec(&app, id).orientation, BlendOrientation::AlignToPage); +} + +#[test] +fn each_button_runs_its_blend_command() { + // Reverse Front to Back reverses the keys' stacking order. + let (mut app, id) = blended(); + let keys = |app: &VectorcraftApp| -> Vec { + app.session.active().unwrap().doc.node(id).unwrap().children().unwrap().iter().map(|k| k.id).collect() + }; + let before = keys(&app); + let mut f = Frames::new(); + f.click_text(&mut app, "Reverse Front to Back"); + assert_eq!(keys(&app), before.iter().rev().copied().collect::>()); + // Reverse Spine swaps the keys' places along the spine. + let x = |app: &VectorcraftApp, k: NodeId| app.session.active().unwrap().doc.node(k).unwrap().geometric_bounds().unwrap().x0; + let (a, b) = (keys(&app)[0], keys(&app)[1]); + let (xa, xb) = (x(&app, a), x(&app, b)); + f.click_text(&mut app, "Reverse Spine"); + assert_eq!((x(&app, a), x(&app, b)), (xb, xa)); + // Replace Spine puts the blend on the selected path, which it takes in. + let line = app.run("shape.line", json!({"x1": 0, "y1": 100, "x2": 200, "y2": 200})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id.0, line]})).unwrap(); + f.click_text(&mut app, "Replace Spine"); + assert!(spec(&app, id).spine.is_some(), "the line is the spine"); + assert!(app.session.active().unwrap().doc.node(NodeId(line)).is_none(), "the line was taken in"); + // Expand turns the blend into a group of its keys and steps. + f.click_text(&mut app, "Expand"); + assert!(matches!(app.session.active().unwrap().doc.node(id).unwrap().kind, NodeKind::Group { .. })); + // Release gives back the keys of another blend. + let (mut app, _) = blended(); + f.click_text(&mut app, "Release"); + let objects = top(&app); + assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}"); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e04d26332..41c015624 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -134,6 +134,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("transform", "Transform", "dc-transform-panel"), ("align", "Align", "dc-align"), ("pathfinder", "Pathfinder", "dc-pathfinder"), + (crate::panels::blend::ID, "Blend", "dc-blend"), ("character", "Character", "type"), ("paragraph", "Paragraph", "pilcrow"), ("glyphs", "Glyphs", "text-cursor-input"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2d33ac7c4..4ccebf55f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -587,6 +587,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **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). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). + Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 339ab70e3..8f8a8861f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -214,7 +214,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains | | Canvas context menu | done | Layers rows, panels and type have none (gap) | -| Blend panel (30.7) | missing | Blend Options dialog exists | +| Blend panel (30.7) | partial | Window › Blend: Make, Spacing, Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, running the Object › Blend commands; the easing and color controls are missing | | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch) | done | | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending |