diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000000..ecee45a6d8 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 906374f4d2..94a82abeef 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -247,10 +247,13 @@ fn decode_saved_prefs(bytes: &[u8]) -> Option { let mut value: Value = serde_json::from_slice(bytes).ok()?; let object = value.as_object_mut()?; recover(object, "docking_hidden"); - if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") { - for workspace in workspaces { - if let Some(object) = workspace.as_object_mut() { - recover(object, "dockingHidden"); + // Saved workspaces and the layout changes kept for each workspace carry the same layouts. + for key in ["custom_workspaces", "workspace_changes"] { + if let Some(Value::Array(workspaces)) = object.get_mut(key) { + for workspace in workspaces { + if let Some(object) = workspace.as_object_mut() { + recover(object, "dockingHidden"); + } } } } @@ -1065,6 +1068,7 @@ mod tests { app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); app.ui.custom_workspaces = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; + app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")]; let original = serde_json::to_value(&app.ui).unwrap(); let valid_location = original["docking_hidden"]["layers"].clone(); assert!(valid_location.is_object()); @@ -1075,6 +1079,8 @@ mod tests { raw["docking_hidden"] = invalid_hidden.clone(); raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); + raw["workspace_changes"][0]["docking"] = invalid_layout.clone(); + raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone(); assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); @@ -1091,6 +1097,8 @@ mod tests { assert!(loaded.custom_workspaces[0].docking.is_none()); assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); + assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost"); + assert!(loaded.workspace_changes[0].docking.is_none()); } } let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); diff --git a/contributors/people.toml b/contributors/people.toml index 33394254fa..5643212674 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -148,5 +148,9 @@ sync_github_name = true [people.addibble] sync_github_name = true +[people.mockydevs] +real_name = "Don Omondi" +display_name = "Don Omondi" + [people.alpha-rock] display_name = "Aditya Sisodiya" diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index ef63fe845a..818a5fbe49 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -264,6 +264,8 @@ pub struct RowMap { mean_l: f32, /// Hue turn from the key colour to the new colour (Hue Shift). turn: f32, + /// Whether colours take the new colour in its own model, not in the model they had. + own_model: bool, } impl RowMap { @@ -274,7 +276,20 @@ impl RowMap { Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::>()), _ => 0.0, }; - Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] } + Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0], own_model: false } + } + + /// With `own`, current colours take the new colour in the new colour's model (an RGB colour + /// replaced by a CMYK one becomes CMYK) instead of keeping the model they had. + pub fn with_own_model(mut self, own: bool) -> Self { + self.own_model = own; + self + } + + /// True when current colour `c` keeps a colour model other than the new colour's (so the new + /// colour is converted into `c`'s model and may look different from what was asked for). + pub fn converts(&self, c: Color) -> bool { + !self.own_model && c.model() != self.to.model() } /// The colour of the row the new colour stands for. @@ -301,7 +316,7 @@ impl RowMap { /// The new colour of current colour `c`, in `c`'s model. pub fn map(&self, c: Color) -> Color { - let to = keep_model(c, self.to); + let to = if self.own_model { self.to } else { keep_model(c, self.to) }; match self.method { Method::Exact => to, Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)), @@ -314,7 +329,13 @@ impl RowMap { Method::HueShift if c == self.key => to, Method::HueShift => { let [h, s, v] = c.to_hsb(); - if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) } + if s <= 0.0 { + if self.own_model { c.in_model(self.to.model()) } else { c } + } else if self.own_model { + Color::from_hsb(h + self.turn, s, v).in_model(self.to.model()) + } else { + keep_model(c, Color::from_hsb(h + self.turn, s, v)) + } } } } @@ -576,6 +597,16 @@ mod tests { assert!(cluster(&[], &[], 3).is_empty()); } + #[test] + fn hue_shift_with_own_model_puts_every_colour_of_the_row_in_the_new_model() { + let to = Color::cmyk(0.0, 1.0, 1.0, 0.0); + let row = [Color::rgb(0.2, 0.2, 0.9), Color::rgb(0.1, 0.1, 0.5), Color::rgb(0.3, 0.3, 0.3)]; + let own = RowMap::new(Method::HueShift, &row, to).with_own_model(true); + assert!(row.iter().all(|c| own.map(*c).model() == to.model()), "key and other colours alike"); + let kept = RowMap::new(Method::HueShift, &row, to); + assert!(row.iter().all(|c| kept.map(*c).model() == c.model())); + } + #[test] fn palette_snaps_to_the_nearest_colour() { let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 3c57411a12..8ba6fb174f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -579,11 +579,18 @@ impl Lerp { /// The object at `t` (0 = `a`, 1 = `b`), with id 0. pub fn at(&self, t: f64) -> Node { + self.at_eased(t, t) + } + + /// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text + /// colours) are at `tc`: a blend with its colours eased apart from its spacing. + pub fn at_eased(&self, t: f64, tc: f64) -> Node { let (a, b) = (&self.a, &self.b); let base = if t < 0.5 { a } else { b }; let mut n = match &self.shape { Shape::Group { children, clip } => { - let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip }); + let mut g = + Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip }); g.isolate = base.isolate; g.knockout = base.knockout; g @@ -593,8 +600,8 @@ impl Lerp { if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) { for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) { p.p = x.p.lerp(y.p, t); - p.color = lerp_color(&x.color, &y.color, t as f32); - p.opacity = lerp_f32(x.opacity, y.opacity, t as f32); + p.color = lerp_color(&x.color, &y.color, tc as f32); + p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32); for h in 0..4 { p.handles[h] = x.handles[h].lerp(y.handles[h], t); } @@ -620,7 +627,7 @@ impl Lerp { Shape::Placed => { let mut n = base.clone(); match (&mut n.kind, &a.kind, &b.kind) { - (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t), + (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc), (NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => { *o = lerp_affine(*x, *y, t) } @@ -657,8 +664,8 @@ impl Lerp { } }; n.id = NodeId(0); - n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into(); - n.opacity = lerp_f32(a.opacity, b.opacity, t as f32); + n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into(); + n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32); n.blend = base.blend; n.visible = true; n.locked = false; @@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N /// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an /// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at /// the same place (by character) of both. -fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { +fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) { o.xf = lerp_affine(x.xf, y.xf, t); if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) = (&mut o.kind, &x.kind, &y.kind) @@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { } let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::().max(1); let mut start = 0usize; - let tf = t as f32; + let tf = tc as f32; let mut resized = false; for r in &mut o.runs { let len = r.text.chars().count(); @@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { let n = blend_step_count(a, b, spec.spacing, len); let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1))); for j in 1..=n { - let t = j as f64 / (n + 1) as f64; - let mut s = lerp.at(t); + // Even position of the step, then where the easing puts its shape and its colours. + let u = j as f64 / (n + 1) as f64; + let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u)); + let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { r.place(&mut s, i, t); } @@ -1134,6 +1143,55 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn easing_keeps_the_ends_and_stays_monotone() { + use crate::live::{BlendEase, BlendEaseType as K}; + for kind in [K::In, K::Out, K::InOut, K::OutIn] { + for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] { + let e = BlendEase { kind, ramp }; + assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0)); + let ys: Vec = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect(); + assert!(ys.windows(2).all(|w| w[0] <= w[1])); + assert_eq!(e.apply(f64::NAN), 0.0); + if ramp == 0.0 { + assert_eq!(e.apply(0.25), 0.25); + } + } + } + let e = |kind| BlendEase { kind, ramp: 1.0 }; + assert!(e(K::In).apply(0.25) < 0.25); + assert!(e(K::Out).apply(0.25) > 0.25); + assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75); + assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75); + } + + #[test] + fn colour_easing_moves_colours_apart_from_positions() { + let rect = |x: f64, c: Color| { + let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()); + n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into(); + Arc::new(n) + }; + let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)]; + let mid = |spec: &BlendSpec| { + let out = blend_expand(&keys, spec); + let n = &out[1]; + (n.geometric_bounds().unwrap().x0, n.appearance.fill_paint()) + }; + // Position easing runs along a spine: Align to Path gives the blend one. + let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() }; + let (x0, p0) = mid(&base); + // Colour easing alone leaves the position and moves the colour. + let colour = BlendSpec { color_shift: 50.0, ..base.clone() }; + let (x1, p1) = mid(&colour); + assert!((x0 - x1).abs() < 1e-9 && p0 != p1); + // Position easing alone leaves the colour and moves the position. + let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() }; + let (x2, p2) = mid(&pos); + assert!((x0 - x2).abs() > 1e-3 && p0 == p2); + assert_eq!(mid(&pos).0, x2, "deterministic"); + } + #[test] fn affine_lerp_turns_the_short_way() { let x = Affine::rotate(170f64.to_radians()); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0817b1cd63..63aad85213 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -99,7 +99,7 @@ pub use graph::{ }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; -pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; +pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use live_paint::{GapOptions, GapSize}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; @@ -935,6 +935,136 @@ type Places = BTreeMap, Vec<(usize, usize)>>; pub const FORMAT_VERSION: u32 = 1; impl Document { + /// Is everything but the layers the same in `other`, as the next version of the document after + /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it + /// doesn't change: quick for every frame of an animation. + pub fn same_outside_layers(&self, other: &Self) -> bool { + let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); + let same_pattern = |a: &PatternDef, b: &PatternDef| { + same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } + }; + let Self { + version, + title, + template, + units, + ruler_origin, + rulers, + artboard_ruler_origins, + color_mode, + artboards, + layers: _, + swatches, + swatch_groups, + graphic_styles, + char_styles, + para_styles, + text_threads, + symbols, + graph_designs, + guides, + views, + saved_selections, + variables, + grid, + raster_effects_ppi, + images, + patterns, + pattern_edit, + mask_edit, + next_id: _, + unknown, + page_isolate, + page_knockout, + spot_use_lab, + setup, + paste_remembers_layers, + metadata, + raster_effects, + last_view, + transparency_grid, + layers_open, + color_profiles, + export_settings, + extra, + slices, + slices_clip_to_artboard, + print_setup, + assets, + svg_scripts, + puppet, + new_art, + dimensions, + index: _, + } = self; + *version == other.version + && *title == other.title + && *template == other.template + && *units == other.units + && *ruler_origin == other.ruler_origin + && *rulers == other.rulers + && *artboard_ruler_origins == other.artboard_ruler_origins + && *color_mode == other.color_mode + && *artboards == other.artboards + && *swatches == other.swatches + && *swatch_groups == other.swatch_groups + && *graphic_styles == other.graphic_styles + && *char_styles == other.char_styles + && *para_styles == other.para_styles + && *text_threads == other.text_threads + && symbols.len() == other.symbols.len() + && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && graph_designs.len() == other.graph_designs.len() + && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && *guides == other.guides + && *views == other.views + && *saved_selections == other.saved_selections + && *variables == other.variables + && *grid == other.grid + && *raster_effects_ppi == other.raster_effects_ppi + && *images == other.images + && patterns.len() == other.patterns.len() + && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) + && match (pattern_edit, &other.pattern_edit) { + (Some(a), Some(b)) => { + a.pattern == b.pattern + && a.layer == b.layer + && match (&a.original, &b.original) { + (Some(a), Some(b)) => same_pattern(a, b), + (a, b) => a.is_none() && b.is_none(), + } + } + (a, b) => a.is_none() && b.is_none(), + } + && *mask_edit == other.mask_edit + && *unknown == other.unknown + && *page_isolate == other.page_isolate + && *page_knockout == other.page_knockout + && *spot_use_lab == other.spot_use_lab + && *setup == other.setup + && *paste_remembers_layers == other.paste_remembers_layers + && *metadata == other.metadata + && *raster_effects == other.raster_effects + && *last_view == other.last_view + && *transparency_grid == other.transparency_grid + && *layers_open == other.layers_open + && *color_profiles == other.color_profiles + && *export_settings == other.export_settings + && *extra == other.extra + && *slices == other.slices + && *slices_clip_to_artboard == other.slices_clip_to_artboard + && *print_setup == other.print_setup + && *assets == other.assets + && *svg_scripts == other.svg_scripts + && match (puppet, &other.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + } + && *new_art == other.new_art + && *dimensions == other.dimensions + } + /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value /// isn't a number). pub fn raster_ppi(&self) -> f64 { diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 9175c88c57..7fac073dc2 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -60,12 +60,75 @@ pub enum BlendOrientation { AlignToPath, } +/// Distribution of blend steps, as offered by the Blend panel's Type menu. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendEaseType { + #[default] + In, + Out, + InOut, + OutIn, +} + +impl BlendEaseType { + pub fn id(self) -> &'static str { + match self { + Self::In => "easeIn", + Self::Out => "easeOut", + Self::InOut => "easeInOut", + Self::OutIn => "easeOutIn", + } + } +} + +/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendEase { + #[serde(default)] + pub kind: BlendEaseType, + #[serde(default, deserialize_with = "blend_ramp")] + pub ramp: f64, +} + +impl BlendEase { + pub fn is_linear(&self) -> bool { + self.ramp <= 0.0 + } + + /// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0. + pub fn is_default(&self) -> bool { + self.kind == BlendEaseType::In && self.ramp == 0.0 + } + + /// A monotone polynomial distribution, blended with even spacing by Ramp. Public help + /// describes the four distributions, not their numeric curves; these curves are original. + pub fn apply(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 }; + let eased = match self.kind { + BlendEaseType::In => u * u, + BlendEaseType::Out => u * (2.0 - u), + BlendEaseType::InOut => u * u * (3.0 - 2.0 * u), + BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u), + BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5), + }; + u + r * (eased - u) + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct BlendSpec { #[serde(default)] pub spacing: BlendSpacing, #[serde(default)] pub orientation: BlendOrientation, + /// Acceleration of the steps' shapes and positions along the spine between two keys. + #[serde(default, skip_serializing_if = "BlendEase::is_default")] + pub ease: BlendEase, + /// Independent color acceleration. -100..100, with 0 preserving the even color ramp. + #[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")] + pub color_shift: f64, /// Replaced spine. `None` = the straight lines between the key objects' centres. #[serde(default, skip_serializing_if = "Option::is_none")] pub spine: Option, @@ -1757,10 +1820,58 @@ pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxe o } +/// Reading a native file never fails over one bad blend number: out-of-range values clamp, +/// and anything that is not a finite number (NaN, null, text) becomes the default. The +/// commands still refuse such input (`ease_param`). +fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max))) +} + +fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, 0.0, 1.0) +} + +fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, -100.0, 100.0) +} + +/// Whether an optional stored color shift has its unchanged default. +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +impl BlendSpec { + /// Shift the color transition independently of shape placement. The exponential curve is + /// original; the documented behavior specifies the direction, not numeric sample values. + pub fn color_position(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 }; + if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) } + } +} + #[cfg(test)] mod tests { use super::*; + /// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of + /// failing the whole document. + #[test] + fn blend_easing_numbers_clamp_when_read() { + use serde::de::value::{Error, F64Deserializer}; + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0)); + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0)); + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0)); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(blend_ramp(F64Deserializer::::new(bad)).unwrap(), 0.0); + assert_eq!(blend_color_shift(F64Deserializer::::new(bad)).unwrap(), 0.0); + } + } + /// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the /// aspect), the top and bottom edges are concentric circles about the bend's centre, at their /// real distance from the centre line; an inner edge that would pass the centre stops there. diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 51b411838c..9be6617087 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1301,10 +1301,8 @@ impl Node { self.projected(Some(b)).map(|b| b.inflate(o, o)) } NodeKind::Text(t) => { - // The characters' strokes paint around the glyphs, scaled with the type. - let k = Scaling::factor(t.xf).unwrap_or(1.0); - let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k; - let o = self.appearance.outset().max(chars); + // The characters' strokes paint around the glyphs. + let o = self.appearance.outset().max(t.char_stroke_outset()); self.geometric(symbols).map(|b| b.inflate(o, o)) } // An image is measured by its frame, without its own fills and strokes. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 3b7df2f420..1dc202553d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -104,6 +104,12 @@ pub struct CharStyle { /// their line line up with it. #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] pub char_align: CharAlign, + /// Character Direction (Character panel menu, Middle Eastern options): these characters are + /// set left to right or right to left inside their paragraph, each stretch of them a Unicode + /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives + /// them. + #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] + pub char_direction: Option, /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take /// its proportional heights, `vpal`. @@ -297,6 +303,7 @@ impl Default for CharStyle { stroke_align: StrokeAlign::Center, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, + char_direction: None, proportional_metrics: false, tate_chu_yoko: false, small_caps: None, @@ -862,7 +869,8 @@ impl From for ParaStyle { } } -/// A paragraph's base direction ([`ParaStyle::direction`]). +/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set +/// in ([`CharStyle::char_direction`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ParaDirection { @@ -1702,6 +1710,12 @@ impl TextObject { TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } + /// How far the characters' strokes paint past their glyphs, in document space: they scale + /// with the type. + pub fn char_stroke_outset(&self) -> f64 { + let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); + self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k + } pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } @@ -1906,6 +1920,22 @@ impl crate::Document { mod tests { use super::*; + /// Character Direction is saved only when set, and styles written before it existed read as + /// Default. + #[test] + fn character_direction_is_saved_only_when_set() { + let st = CharStyle::default(); + assert_eq!(st.char_direction, None); + assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); + for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { + let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); + } + /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. #[test] fn ruby_is_saved_and_missing_ruby_reads_as_none() { diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index a7d8b40316..067064950a 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -127,6 +127,25 @@ fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() { assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted"); } +#[test] +fn expand_stroke_region_of_a_round_line_stays_compact() { + use std::f64::consts::PI; + use vectorcraft_geom::{FillRule, PathData, Point, SubPath}; + let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false)); + let st = stroke(10.0, |s| s.cap = vectorcraft_doc::LineCap::Round); + let region = crate::stroke::outline::outline_region(&path, FillRule::NonZero, &st); + let anchors = region.subpaths.iter().map(|s| s.anchors.len()).sum::(); + assert!(anchors <= 8, "expand stroke should use few anchors for a round line, got {anchors}"); + let area = region.subpaths.iter().map(|s| s.area()).sum::().abs(); + assert!(near(area, 100.0 * 10.0 + PI * 5.0 * 5.0, 1.0), "{area}"); + // Extra colinear anchors along the spine must not multiply outline anchors (#3446). + let dense: Vec = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect(); + let dense_path = PathData::single(SubPath::polyline(&dense, false)); + let dense_region = crate::stroke::outline::outline_region(&dense_path, FillRule::NonZero, &st); + let dense_anchors = dense_region.subpaths.iter().map(|s| s.anchors.len()).sum::(); + assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors"); +} + #[test] fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let l = line(0.0, 100.0); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index c8fe409d61..694ae98076 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -119,7 +119,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -758,11 +758,13 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// Sample enabled attributes, refusing object transparency that a text range cannot store. fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false)); let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None }; let selected = match ids_param(p, "ids") { Some(v) => outermost(&s.doc()?.doc, v), None => selected_roots(s)?, @@ -776,12 +778,38 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { }; targets.retain(|id| *id != src_id); let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?); + let range = range.filter(|(id, _, _)| targets.contains(id)); + // Character styles cannot represent object transparency. Refuse before even changing + // paint defaults; applying it to the object would also alter the unselected characters. + if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) { + let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal { + return Err(bad( + C, + "object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object", + )); + } + } if !reverse { picked.to_defaults(s, &attrs.appearance); } if targets.is_empty() || attrs.is_empty() { return Ok(json!({ "ids": [] })); } + if let Some((id, start, end)) = range { + let before = s.doc()?.doc.clone(); + super::overprint::edit_counted(s, "Eyedropper", |d| { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let placed = picked.placed(Some(t.local_bounds())); + vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); + if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { + t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); + } + super::typecmd::refresh_bounds(t); + Ok(count_changed(d, &before, &[id])) + })?; + return Ok(json!({ "ids": [id.0] })); + } // When every target comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Eyedropper", |d| { @@ -843,6 +871,22 @@ impl Picked { } } + /// Copy character attributes and the enabled paint attributes through the same path for + /// whole text objects and highlighted character ranges. + fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) { + if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) { + *st = CharStyle { + fill: std::mem::take(&mut st.fill), + stroke: std::mem::take(&mut st.stroke), + stroke_width: st.stroke_width, + overprint_fill: st.overprint_fill, + overprint_stroke: st.overprint_stroke, + ..cs.clone() + }; + } + run_attrs(st, placed, &a.appearance); + } + /// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its /// stack instead. Type takes the paints on its characters, and character and paragraph /// attributes from type. @@ -862,19 +906,7 @@ impl Picked { let placed = self.placed(Some(t.local_bounds())); let text = self.text.as_ref(); for r in &mut t.runs { - if let Some((cs, _)) = text.filter(|_| a.character) { - // The character attributes; the paints follow the appearance attributes. - let st = &mut r.style; - *st = CharStyle { - fill: std::mem::take(&mut st.fill), - stroke: std::mem::take(&mut st.stroke), - stroke_width: st.stroke_width, - overprint_fill: st.overprint_fill, - overprint_stroke: st.overprint_stroke, - ..cs.clone() - }; - } - run_attrs(&mut r.style, &placed, ap); + self.run(&mut r.style, &placed, a); } if let Some((_, para)) = text.filter(|_| a.paragraph) { // The source's first paragraph's attributes, on every paragraph. diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 0c186177fa..15d681189e 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Shape Builder", [], None, - "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)", + "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)", has_selection_or_ids, sb_merge ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Shape Builder Regions", [], None, - "{ids?|id?, openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes", + "{ids?|id?, gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes", has_selection_or_ids, sb_regions ), @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", has_doc, lp_fill ), @@ -236,6 +236,12 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result> { } } +/// Gap Detection length in points, bounded like Live Paint's custom gap size. +fn gap_param(p: &Value) -> f64 { + let gap = f64_or(p, "gap", 0.0); + if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 } +} + fn sb_merge(s: &mut Session, p: &Value) -> Result { const C: &str = "shapeBuilder.merge"; let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?; @@ -262,7 +268,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { let roots = root_ids(s, p)?; let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; let (ids, merged, touched) = s.edit(label, |d, sel| { - let (map, leaves) = BuilderMap::new(d, &roots, erase, open_filled_as_closed); + let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p)); let leaves: Vec = leaves.into_iter().cloned().collect(); if map.shapes.is_empty() { return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); @@ -280,7 +286,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { // Edges come back from the planar map within a few hundredths of the outlines they follow. let cut_tol = step * 0.2; let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let faces = po::FaceMerger::new(&map.shapes); + let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; // The merged shape takes the look of the front-most object over the first region, or (for an // area only lines enclose) of the front-most object and the current fill. @@ -378,7 +384,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec) -> Result fn sb_regions(s: &mut Session, p: &Value) -> Result { let roots = root_ids(s, p)?; let d = &s.doc()?.doc; - let (map, leaves) = BuilderMap::new(d, &roots, true, bool_or(p, "openFilledAsClosed", true)); + let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p)); let id_of = |i: usize| leaves.get(i).map(|n| n.id.0); let regions: Vec = map .regions @@ -764,13 +770,24 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result { } g } - None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) { - Some(g) => g, - None => make_live(d, sel, &roots)?.0, - }, + None => { + let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { + roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) + }; + match (live_at(b::visible_face_at), live_at(face_at)) { + (Some(g), _) => g, + // At a point on a hidden face of a Live Paint group among the objects, the command returns an error + // and does not merge the objects. + (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), + (None, None) => make_live(d, sel, &roots)?.0, + } + } }; - let fid = - d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; + let fid = d + .node(gid) + .and_then(|n| b::visible_face_at(n, pt)) + .map(|f| f.id) + .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); Ok((gid, fid)) })?; diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 0f6debd882..9d417c7d96 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::live::{ - self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance, - PreserveShape, Spine, + self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, + GradientMesh, MeshAppearance, PreserveShape, Spine, }; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", has_doc, blend_make ), @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Blend Options…", ["Object", "Blend"], None, - "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}", + "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}", always, blend_options ), @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), @@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation { } } +/// Blend panel Type and Ramp, validated before any document mutation. +fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result { + let kind = match p.get("easingType").filter(|v| !v.is_null()) { + None => current.kind, + Some(v) => match v.as_str() { + Some("easeIn") => BlendEaseType::In, + Some("easeOut") => BlendEaseType::Out, + Some("easeInOut") => BlendEaseType::InOut, + Some("easeOutIn") => BlendEaseType::OutIn, + _ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")), + }, + }; + Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 }) +} + +/// Bounded percentage input; missing or null preserves the current value. +fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (min..=100.0).contains(n)) + .ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))), + } +} + /// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { const C: &str = "object.blend.make"; @@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); + let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), + _ => Default::default(), + }; + let ease = ease_param(p, C, ease0)?; + let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { wrap(d, sel, &roots, |id, nodes| { let (children, starts) = merge_keys(nodes, &starts); @@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { fn blend_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.options"; let Some((_, cur, _)) = selected_spec(s) else { - // Nothing to set them on: the options new blends start with. + if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { + return Err(bad(C, "select a blend to set easing or color shift")); + } + // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); @@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result { }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); + let ease = ease_param(p, C, cur.ease)?; + let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?; edit_blends(s, "Blend Options", |_, spec| { spec.spacing = spacing; spec.orientation = orientation; + spec.ease = ease; + spec.color_shift = color_shift; }) } @@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result { let mut v = blend_options_json(spec.spacing, spec.orientation); v["target"] = json!("blend"); v["id"] = json!(id.0); + v["easingType"] = json!(spec.ease.kind.id()); + v["ramp"] = json!(spec.ease.ramp * 100.0); + v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 0db52da4b4..d4cccc2126 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -327,6 +327,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "direction", "leadingModel", "charAlign", + "charDirection", "proportionalMetrics", "tateChuYoko", "noBreak", @@ -350,6 +351,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }; let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; let char_align = super::textedit::char_align_param(p, C)?; + let char_direction = super::textedit::char_direction_param(p, C)?; let digits = super::textedit::digits_param(p, C)?; let font_axes = super::textedit::font_axes_param(p, C)?; let mojikumi = match p.get("mojikumi") { @@ -460,6 +462,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = char_align { st.char_align = v; } + if let Some(v) = char_direction { + st.char_direction = v; + } if let Some(v) = digits { st.digits = v; } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 8ed9820b7a..0b713341a7 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -491,7 +491,7 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool { let own = |w: &vectorcraft_color::Swatch| pattern_name(&w.paint).is_some_and(|p| p == old); let mut holders = d.swatches_iter().filter(|w| w.name == new).peekable(); let only_own = holders.peek().is_some() && holders.all(own); - d.swatch_name_taken(new) && !(only_own && !d.swatches_iter().any(|w| w.name == old && own(w))) + d.swatch_name_taken(new) && (!only_own || d.swatches_iter().any(|w| w.name == old && own(w))) } /// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2855fcb081..16548fc883 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -123,7 +123,7 @@ fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, St } if super::effectcmd::texture_load_effect(&e.id) && let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE) - && !t.as_str().is_some_and(|t| effects::pixel::texture(t).is_some()) + && t.as_str().is_none_or(|t| effects::pixel::texture(t).is_none()) { return Err(refuse("`textureImage` must be a texture as effect.list gives it".into())); } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index f48082e6ba..d9a8b487ab 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces, except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches]}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], note?: set when a colour kept a model other than its new colour's (see model)}", has_doc, apply ), @@ -268,17 +268,19 @@ struct ColorMap { links: Vec>, keys: HashMap, hexes: HashMap, + /// Set when a colour took a new colour of another model in its own model. + converted: std::cell::Cell, } impl ColorMap { /// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none). - fn new(rows: &[Row], method: Method, links: &[Option]) -> Self { - let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new() }; + fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option]) -> Self { + let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() }; for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) { let Some(to) = r.new_color() else { continue }; let i = out.rows.len(); let colors: Vec = r.from.iter().map(Match::color).collect(); - out.rows.push(RowMap::new(method, &colors, to)); + out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model)); out.links.push(links.get(n).cloned().flatten()); for m in &r.from { match m { @@ -296,7 +298,11 @@ impl ColorMap { } fn map(&self, c: Color) -> Color { - self.row_of(c).and_then(|i| self.rows.get(i)).map_or(c, |r| r.map(c)) + let Some(r) = self.row_of(c).and_then(|i| self.rows.get(i)) else { return c }; + if r.converts(c) { + self.converted.set(true); + } + r.map(c) } /// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot @@ -315,7 +321,12 @@ impl ColorMap { return changed; } (Some(l), Some(t)) => l.color.tinted(t), - _ => row.map(*c), + _ => { + if row.converts(*c) { + self.converted.set(true); + } + row.map(*c) + } }; if new == *c { return false; @@ -351,17 +362,42 @@ fn apply(s: &mut Session, p: &Value) -> Result { } let scope = Scope { fill: true, stroke: true, ..Scope::of(p) }; let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" }; + // `model`: keep each colour's model (default), take the new colour's own, or the document's. + // A given `model` must be a string; only a missing one means "keep". + let model = match p.get("model") { + None => "keep", + Some(Value::String(m)) => m.as_str(), + Some(other) => return Err(bad(C, format!("bad model {other} (keep, new or document)"))), + }; + let mut doc_model = None; + let own_model = match model { + "keep" => false, + "new" => true, + "document" => { + let m = s.doc()?.doc.color_mode.model(); + doc_model = Some(m); + for r in &mut rows { + r.to = r.to.map(|c| c.in_model(m)); + } + true + } + other => return Err(bad(C, format!("unknown model `{other}` (keep, new or document)"))), + }; + // The colours a colour group takes follow the model too (`document` converts them). + let in_doc_model = |c: Color| doc_model.map_or(c, |m| c.in_model(m)); let new: Vec = match p.get("groupColors").and_then(Value::as_array) { Some(cs) => cs .iter() .map(|v| { - color_value(v).map(|c| palette.as_ref().map_or(c, |pl| pl.nearest(c))).ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) + color_value(v) + .map(|c| in_doc_model(palette.as_ref().map_or(c, |pl| pl.nearest(c)))) + .ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) }) .collect::>()?, None => rows.iter().filter_map(Row::new_color).collect(), }; let rename = str_param(p, "rename"); - let (changed, edit, added) = s.edit(label, |d, _| { + let (changed, edit, added, converted) = s.edit(label, |d, _| { // The rows' library swatches join the document (one it has with that name and colour is // used as it is). let mut added = vec![]; @@ -382,17 +418,24 @@ fn apply(s: &mut Session, p: &Value) -> Result { Some(Link { name, global: w.global, color }) }) .collect(); - let map = ColorMap::new(&rows, method, &links); + let map = ColorMap::new(&rows, method, own_model, &links); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; - Ok((changed, edit, added)) + Ok((changed, edit, added, map.converted.get())) })?; if let Some(e) = edit { e.defaults(s); } - Ok(json!({ "changed": changed, "swatches": added })) + let mut out = json!({ "changed": changed, "swatches": added }); + if converted { + out["note"] = json!( + "some colours kept their own colour model, so the new colour was converted into it and may look different; \ + pass model \"new\" (the new colour's model) or \"document\" (the document's colour mode) to change the model" + ); + } + Ok(out) } fn randomize(_: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c51c05846c..98d31cfb78 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -256,6 +256,7 @@ pub(crate) struct CharChange { position: Option, small_caps: Option>, char_align: Option, + char_direction: Option>, proportional_metrics: Option, tate_chu_yoko: Option, no_break: Option, @@ -263,6 +264,19 @@ pub(crate) struct CharChange { font_axes: Option, } +/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` +/// inside: Default). +pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result>> { + use vectorcraft_doc::ParaDirection; + let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) }; + Ok(Some(match v.as_str() { + Some("default") => None, + Some("leftToRight") => Some(ParaDirection::LeftToRight), + Some("rightToLeft") => Some(ParaDirection::RightToLeft), + _ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")), + })) +} + /// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in. pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result> { use vectorcraft_doc::Digits; @@ -416,6 +430,7 @@ impl CharChange { position, small_caps, char_align: char_align_param(p, cmd)?, + char_direction: char_direction_param(p, cmd)?, proportional_metrics: flag("proportionalMetrics"), tate_chu_yoko: flag("tateChuYoko"), no_break: flag("noBreak"), @@ -459,6 +474,7 @@ impl CharChange { && self.position.is_none() && self.small_caps.is_none() && self.char_align.is_none() + && self.char_direction.is_none() && self.proportional_metrics.is_none() && self.tate_chu_yoko.is_none() && self.no_break.is_none() @@ -554,6 +570,9 @@ impl CharChange { if let Some(v) = self.char_align { st.char_align = v; } + if let Some(v) = self.char_direction { + st.char_direction = v; + } if let Some(v) = self.proportional_metrics { st.proportional_metrics = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 77984967ca..cf3585f591 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -155,6 +155,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("position", json!(v.position)), ("small_caps", json!(v.small_caps)), ("charAlign", json!(v.char_align)), + ("charDirection", json!(v.char_direction)), ("proportionalMetrics", json!(v.proportional_metrics)), ("tateChuYoko", json!(v.tate_chu_yoko)), ("noBreak", json!(v.no_break)), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 89ec479585..67fcccd120 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1599,6 +1599,47 @@ mod area_tests { } } + /// Character Direction (Character panel menu, Middle Eastern options): a character attribute + /// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when + /// set and kept by a native save. + #[test] + fn character_direction_is_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::ParaDirection; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(); + assert_eq!(runs(&s)[0].style.char_direction, None); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] { + s.execute("text.setFormat", &json!({"charDirection": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction == want)); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key)); + } + let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string(); + assert!(err.contains("`charDirection` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none())); + s.execute("edit.redo", &json!({})).unwrap(); + // Saved and reopened as it was. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect(); + assert_eq!(reopened, set(&s)); + // A character style captured from the characters carries it, Default included. + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight"); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null)); + } + /// Proportional Metrics (#966): a character attribute, saved only when on. #[test] fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs index ab70333d28..4682ba1eac 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests_blendopts.rs @@ -153,3 +153,79 @@ fn start_points_round_trip_and_old_blends_load() { assert!(old.starts.is_empty() && old.start(0).is_none()); assert!(serde_json::to_value(&old).unwrap().get("starts").is_none()); } + +#[test] +fn easing_is_set_validated_saved_and_old_blends_are_even() { + let mut s = session(); + assert!(s.execute("object.blend.options", &json!({"ramp": 50})).is_err()); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeIn", "ramp": 40})).unwrap()); + assert_eq!(spec(&s, g).1.ease.ramp, 0.4); + sel(&mut s, &[g]); + for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] { + s.execute("object.blend.options", &json!({"easingType": kind, "colorShift": -50})).unwrap(); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((&info["easingType"], &info["ramp"], &info["colorShift"]), (&json!(kind), &json!(40.0), &json!(-50.0))); + } + let before = spec(&s, g).1; + let depth = s.doc().unwrap().history.undo.len(); + for bad in [json!({"ramp": 101}), json!({"ramp": -1}), json!({"colorShift": "x"}), json!({"colorShift": 1e308}), json!({"easingType": "unknown"})] + { + assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}"); + assert_eq!(spec(&s, g).1, before); + assert_eq!(s.doc().unwrap().history.undo.len(), depth); + } + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + s.execute("object.blend.options", &json!({"ramp": 0})).unwrap(); + let zero = spec(&s, g).1; + let back: BlendSpec = serde_json::from_value(serde_json::to_value(&zero).unwrap()).unwrap(); + assert_eq!(back.ease, zero.ease, "a chosen Type survives at Ramp 0"); + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap(); + assert!(old.ease.is_linear() && old.color_shift == 0.0); + let v = serde_json::to_value(&old).unwrap(); + assert!(v.get("ease").is_none() && v.get("colorShift").is_none()); +} + +#[test] +fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeOut", "ramp": 40, "colorShift": -50})).unwrap()); + let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let blend_of = |doc: &vectorcraft_doc::Document| match doc.node(g).unwrap().kind.clone() { + NodeKind::Blend { spec, .. } => *spec, + _ => panic!("not a blend"), + }; + for (ramp, shift, want) in [("12.5", "-9000.0", (1.0, -100.0)), ("-3.0", "250.0", (0.0, 100.0)), ("null", "\"NaN\"", (0.0, 0.0))] { + let t = text.replacen("\"ramp\":0.4", &format!("\"ramp\":{ramp}"), 1).replacen("\"colorShift\":-50.0", &format!("\"colorShift\":{shift}"), 1); + assert!(t != text && t.contains("\"ramp\":") && t.contains(shift), "the saved blend carries ramp and colorShift"); + let doc = vectorcraft_format::load(t.as_bytes()).expect("one bad blend number must not fail the document"); + let spec = blend_of(&doc); + assert_eq!((spec.ease.ramp, spec.color_shift), want, "{ramp} / {shift}"); + } + // The commands still refuse the same values and leave the blend alone. + sel(&mut s, &[g]); + let before = spec(&s, g).1; + for bad in [json!({"ramp": 1250}), json!({"ramp": -3}), json!({"colorShift": 250}), json!({"colorShift": "NaN"}), json!({"colorShift": 1e308})] { + assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}"); + assert_eq!(spec(&s, g).1, before); + } +} + +#[test] +fn native_blend_easing_values_clamp_and_non_finite_become_default() { + for (bad, ramp, shift) in [ + (json!({"ease": {"ramp": 1e308}}), 1.0, 0.0), + (json!({"ease": {"ramp": -0.1}}), 0.0, 0.0), + (json!({"colorShift": 101}), 0.0, 100.0), + (json!({"colorShift": -101}), 0.0, -100.0), + (json!({"ease": {"ramp": null}, "colorShift": "NaN"}), 0.0, 0.0), + ] { + let spec: BlendSpec = serde_json::from_value(bad.clone()).unwrap_or_else(|e| panic!("{bad}: {e}")); + assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}"); + } +} diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 773471f01e..552106dbc3 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -358,6 +358,55 @@ fn shape_builder_fills_an_area_lines_enclose() { assert_eq!(ids(&r).len(), 4, "the triangle + the three lines, which only bound it"); } +/// Two diagonals and a crossbar that stops about 3 pt short of each: the triangle they bound. +fn lines_with_gaps(s: &mut Session) { + let l1 = line(s, (0.0, 0.0), (100.0, 100.0)); + let l2 = line(s, (100.0, 0.0), (0.0, 100.0)); + let l3 = line(s, (24.0, 80.0), (76.0, 80.0)); + s.execute("select.set", &json!({"ids": [l1.0, l2.0, l3.0]})).unwrap(); +} + +#[test] +fn shape_builder_gap_detection_closes_small_gaps_between_lines() { + let mut s = session(); + lines_with_gaps(&mut s); + let regions = |s: &mut Session, p: Value| s.execute("shapeBuilder.regions", &p).unwrap()["regions"].as_array().unwrap().len(); + assert_eq!(regions(&mut s, json!({})), 0, "off by default"); + assert_eq!(regions(&mut s, json!({"gap": 2})), 0, "the gap is longer"); + assert_eq!(regions(&mut s, json!({"gap": 3})), 1); + assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).is_err(), "no gap, no region"); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]], "gap": 3})).unwrap(); + let merged = NodeId(r["merged"].as_u64().unwrap()); + let a = area(&s, merged); + assert!(a > 850.0 && a < 900.0, "the triangle and its two bridges: {a}"); + assert_eq!(ids(&r).len(), 4, "the bridges are no art: the merged shape and the three lines"); + assert_eq!(open_paths(&s, &ids(&r)).len(), 3); +} + +#[test] +fn shape_builder_tool_gap_detection_option() { + let mut s = session(); + lines_with_gaps(&mut s); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + assert_eq!((s.tool_options()["gapDetection"].clone(), s.tool_options()["gapLength"].clone()), (json!(false), json!(3.0))); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 70.0), v).unwrap(); + }; + click(&mut s); + assert_eq!(layer_children(&s), 3, "Gap Detection is off"); + s.set_tool_option_cmd(&json!({"values": {"gapDetection": true, "gapLength": 6.0}})).unwrap(); + click(&mut s); + assert_eq!(layer_children(&s), 4, "the triangle"); + // The options are kept when another tool is chosen. + s.select_tool("selection", v).unwrap(); + assert_eq!( + (s.tool_options_of("shapeBuilder")["gapDetection"].clone(), s.tool_options_of("shapeBuilder")["gapLength"].clone()), + (json!(true), json!(6.0)) + ); +} + #[test] fn shape_builder_closes_filled_open_paths() { let mut s = session(); @@ -689,6 +738,32 @@ fn live_paint_stroke_edge_skips_hidden_edges() { assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50]})).is_err()); } +#[test] +fn live_paint_fill_skips_hidden_faces() { + let mut s = session(); + let g = live(&mut s); + // The face from x=50 to x=100 is hidden on its own; the group stays visible. + let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(75.0, 50.0)).unwrap().id; + s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap(); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let children = node(&s, g).children().unwrap().len(); + // livePaint.fill at a point on the hidden face returns an error and fills no face, with `group` + // and with the group in `ids`, and it does not rebuild the group. + for p in [json!({"group": g.0, "point": [75, 50], "color": "#00ff00"}), json!({"ids": [g.0], "point": [75, 50], "color": "#00ff00"})] { + assert!(s.execute("livePaint.fill", &p).is_err(), "{p}"); + let face = node(&s, hidden); + assert!(!face.visible && face.appearance.fill_paint() != green, "{p}"); + assert_eq!(node(&s, g).children().unwrap().len(), children, "{p}"); + } + // Given another Live Paint group with a face at that point as well, that face is filled. + let other = rect(&mut s, 60.0, 40.0, 20.0, 20.0); + let other = NodeId(s.execute("livePaint.make", &json!({"ids": [other.0]})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("livePaint.fill", &json!({"ids": [g.0, other.0], "point": [75, 50], "color": "#00ff00"})).unwrap(); + assert_eq!(r["group"], other.0); + assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), green); + assert!(!node(&s, hidden).visible); +} + #[test] fn live_paint_release_and_expand() { let mut s = session(); diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index a24b778620..fdb53fe4b5 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -509,3 +509,162 @@ fn type_takes_the_stroke_options_it_has() { assert_eq!((st.stroke.color().unwrap().to_hex(), st.stroke_width, st.stroke_cap), ("#0000ff".into(), 5.0, LineCap::Round)); assert!(st.stroke_dash.is_none(), "the dash pattern isn't applied"); } + +/// Unsupported object transparency must fail atomically; opting out still samples the range. +#[test] +fn eyedropper_text_range_rejects_object_transparency_without_changes() { + for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("transparency.set", &transparency).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + s.tool_text("MAMA NA KAZI", view).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + s.select_tool("eyedropper", view).unwrap(); + s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let paint = s.paint.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let selection = s.tool_options(); + let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err(); + assert!(err.to_string().contains("object transparency"), "{err}"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.paint, paint); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(s.tool_options(), selection); + // Disabling transparency still copies the paint to just the highlighted characters. + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap(); + let dst = s.doc().unwrap().selection.objects[0]; + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.runs[0].text, "MAMA"); + assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00"); + assert_eq!(t.runs[1].text, " NA KAZI"); + let NodeKind::Text(before) = &doc.node(dst).unwrap().kind else { panic!() }; + assert_eq!(t.runs[1].style, before.runs.last().unwrap().style); + } +} + +/// An opaque sample cannot replace only a range's inherited object transparency. +#[test] +fn eyedropper_text_range_rejects_target_transparency_without_changes() { + for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + s.tool_text("MAMA NA KAZI", view).unwrap(); + let dst = s.doc().unwrap().selection.objects[0]; + s.execute("transparency.set", &transparency).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + s.select_tool("eyedropper", view).unwrap(); + s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let paint = s.paint.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let selection = s.tool_options(); + let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err(); + assert!(err.to_string().contains("object transparency"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.paint, paint); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(s.tool_options(), selection); + // An explicit opt-out samples the range while retaining the object's transparency. + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap(); + let n = node(&s, dst); + assert_eq!((n.opacity, n.blend), (before.node(dst).unwrap().opacity, before.node(dst).unwrap().blend)); + let NodeKind::Text(t) = n.kind else { panic!("not text") }; + assert_eq!(t.runs[0].text, "MAMA"); + assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00"); + let NodeKind::Text(old) = &before.node(dst).unwrap().kind else { panic!("not text") }; + assert_eq!(t.runs[1].text, " NA KAZI"); + assert_eq!(t.runs[1].style, old.runs.last().unwrap().style); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } +} + +/// Choosing the Eyedropper from highlighted type must preserve the character target. +#[test] +fn eyedropper_styles_only_highlighted_characters() { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Source"})).unwrap()); + s.execute("text.setStyle", &json!({"font": "Inter", "size": 30, "fill": "#00ff00"})).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + let dst = s.doc().unwrap().selection.objects[0]; + s.tool_text("MAMA NA KAZI", view).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + let before = match node(&s, dst).kind { + NodeKind::Text(t) => t.first_style(), + _ => panic!(), + }; + s.select_tool("eyedropper", view).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.runs.len(), 2, "only MAMA takes the sampled style"); + assert_eq!(t.runs[0].text, "MAMA"); + assert_eq!(t.runs[0].style.size, 30.0); + assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00"); + assert_eq!(t.runs[1].text, " NA KAZI"); + assert_eq!(t.runs[1].style, before); + s.execute("edit.undo", &json!({})).unwrap(); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.plain_text(), "MAMA NA KAZI", "undo keeps the separate typing step"); + assert_eq!(t.runs[0].style, before); + s.execute("edit.redo", &json!({})).unwrap(); + s.select_tool("type", view).unwrap(); + assert_eq!(s.tool_options()["editing"], dst.0); + assert_eq!((s.tool_options()["start"].as_u64(), s.tool_options()["end"].as_u64()), (Some(0), Some(4))); +} + +/// Color-only sampling preserves multibyte ranges and clears stale targets on deselection. +#[test] +fn eyedropper_shift_color_preserves_other_characters_and_clears_on_deselect() { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + let dst = s.doc().unwrap().selection.objects[0]; + s.tool_text("Aé中Z", view).unwrap(); + s.set_tool_option("select", &json!({"start": 1, "end": 6})); + let NodeKind::Text(before) = node(&s, dst).kind else { panic!() }; + click(&mut s, 325.0, 35.0, Mods { shift: true, ..Default::default() }); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::>(), ["A", "é中", "Z"]); + assert_eq!(t.runs[0].style, before.first_style()); + assert_eq!(t.runs[2].style, before.first_style()); + assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#00ff00"); + assert_eq!(t.runs[1].style.size, before.first_style().size); + assert!(s.overlays(view).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })), "the highlighted range remains visible"); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.tool_options().get("editing").is_none_or(Value::is_null)); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert!( + t.runs.iter().all(|r| r.style.fill.color().unwrap().to_hex() == "#00ff00"), + "after deselecting, ordinary object sampling styles the whole object" + ); +} diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests_recolor.rs index 6cf08905ac..bca05af5cb 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests_recolor.rs @@ -369,3 +369,54 @@ fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() { let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "limitTo": lib})); assert_eq!(r["swatches"], json!(["Leaf"])); } + +#[test] +fn model_param_chooses_the_model_of_the_new_colour() { + // RGB art in a CMYK document: by default a CMYK replacement comes out RGB (#2170). + let mut s = session(); + run(&mut s, "file.new", json!({"width": 400, "height": 200, "colorMode": "cmyk"})); + let art = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"ids": [art.0], "color": "#1a1a1a", "keepModel": true})); + assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Rgb); + select_all(&mut s); + let map = json!({"#1a1a1a": {"c": 0, "m": 0, "y": 0, "k": 100}}); + let r = run(&mut s, "recolor.apply", json!({"map": map})); + assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Rgb, "kept RGB"); + assert!(r["note"].as_str().is_some_and(|n| n.contains("model")), "warns the model was kept: {r}"); + run(&mut s, "edit.undo", json!({})); + let r = run(&mut s, "recolor.apply", json!({"map": map, "model": "new"})); + assert_eq!(fill(&s, art), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + assert!(r.get("note").is_none(), "no note when the model follows the new colour: {r}"); + run(&mut s, "edit.undo", json!({})); + // "document" converts a new RGB colour into the document's CMYK. + run(&mut s, "recolor.apply", json!({"map": {"#1a1a1a": "#ff0000"}, "model": "document"})); + assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Cmyk); + assert!(s.execute("recolor.apply", &json!({"map": map, "model": "nope"})).is_err()); +} + +#[test] +fn model_param_must_be_a_string_when_given() { + let mut s = session(); + let art = rect(&mut s, json!("#1a1a1a")); + select_all(&mut s); + for bad in [json!(42), json!(null), json!(true)] { + assert!(s.execute("recolor.apply", &json!({"map": {"#1a1a1a": "#ff0000"}, "model": bad})).is_err(), "{bad}"); + } + assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed"); +} + +#[test] +fn document_model_converts_the_colours_of_a_group_too() { + let mut s = session(); + run(&mut s, "file.new", json!({"width": 400, "height": 200, "colorMode": "cmyk"})); + run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["cmyk 0 0 0 50"]})); + let group = |s: &Session| -> Vec { + let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap(); + g.swatches.iter().filter_map(|w| w.paint.color()).collect() + }; + // Group-only edit from the rows, and with explicit group colours. + run(&mut s, "recolor.apply", json!({"map": {"cmyk 0 0 0 50": "#ff0000"}, "group": "Theme", "model": "document"})); + assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s)); + run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#00ff00"], "model": "document"})); + assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s)); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 3a868a390a..bb185c9b41 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -92,10 +92,19 @@ impl Session { if self.tool.id() == id { return Ok(()); } + // Sampling type keeps its highlighted characters as the target. Capture before the + // Type tool deactivates (which commits typing and clears its editing state). + let is_type = |id: &str| matches!(id, "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath"); + let text_selection = ((id == "eyedropper" && is_type(self.tool.id())) || (self.tool.id() == "eyedropper" && is_type(id))) + .then(|| self.tool.options()) + .filter(|o| o.get("editing").is_some_and(|v| !v.is_null()) && o.get("start") != o.get("end")); let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); self.apply_actions(acts)?; self.keep_tool_settings(); self.tool = self.make_tool(id); + if let Some(selection) = text_selection { + self.tool.set_option("textSelection", &selection); + } if vectorcraft_tools::catalog::is_selection_tool(id) { self.last_selection_tool = Some(self.tool.id()); } @@ -512,9 +521,9 @@ impl Session { } /// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]): - /// the Type tool stops editing text that is no longer selected. Only while it edits text. + /// the Type tool and the Eyedropper clear a character range whose text is no longer selected. pub(crate) fn after_command(&mut self) { - if self.in_tool_actions || !self.tool.wants_text() { + if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") { return; } let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx)); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index c502fd6634..4d8a1453e9 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -472,6 +472,8 @@ fn structured_junk() { ("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})), ("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})), ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), + ("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})), + ("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})), ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})), @@ -489,6 +491,21 @@ fn structured_junk() { ("symbol.options", json!({"name": "Dot", "nineSlice": true, "nineSliceGuides": [1e308, -1e308, 0, 0]})), ("symbol.options", json!({"nineSlice": 7, "nineSliceGuides": []})), ("symbol.options", json!({"nineSlice": false, "nineSliceGuides": null})), + // Shape Builder Gap Detection: the `gap` param and the tool options. + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": 1e308})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80], [120, 90]], "gap": -1e308})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": f64::MAX})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "NaN"})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "1e999"})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": null})), + ("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": [1, 2]})), + ("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": {"a": 1}, "erase": true})), + ("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": 1e-300, "erase": true, "tolerance": 1e308})), + ("shapeBuilder.regions", json!({"gap": 1e308})), + ("shapeBuilder.regions", json!({"ids": [0, 1, 2, 3], "gap": -1})), + ("shapeBuilder.regions", json!({"ids": [2, 3], "gap": "x"})), + ("shapeBuilder.regions", json!({"gap": true})), + ("shapeBuilder.regions", json!({"gap": 12, "ids": [u64::MAX]})), ]; let mut failures = vec![]; for fx in Fixture::ALL { @@ -501,6 +518,44 @@ fn structured_junk() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// Shape Builder Tool Options (Gap Detection) with junk values: never a panic, and the tool still +/// answers with options and works on a drag afterwards. +#[test] +fn shape_builder_options_junk() { + let cases = [ + json!({"values": {"gapDetection": true, "gapLength": 1e308}}), + json!({"values": {"gapDetection": "yes", "gapLength": -1e308}}), + json!({"values": {"gapDetection": null, "gapLength": "NaN"}}), + json!({"values": {"gapDetection": [true], "gapLength": [1, 2]}}), + json!({"values": {"gapDetection": true, "gapLength": "1e999"}}), + json!({"values": {"gapLength": {"a": 1}, "bogus": 1}}), + json!({"key": "gapLength", "value": f64::MAX}), + json!({"key": "gapLength", "value": f64::MIN_POSITIVE}), + json!({"key": "gapDetection"}), + json!({"key": "gapDetection", "value": 1}), + json!({"values": [1, 2]}), + json!({"values": "x"}), + ]; + let mut failures = vec![]; + for fx in Fixture::ALL { + for p in &cases { + let mut s = fx.session(); + let r = catch_quiet(|| { + let _ = s.select_tool("shapeBuilder", Default::default()); + let _ = s.set_tool_option_cmd(p); + let _ = s.set_tool_option_cmd(&json!({"values": {"gapDetection": true}})); + no_panic(s.execute("shapeBuilder.regions", &json!({}))) + }); + if let Err(msg) = r { + failures.push(format!("PANIC {p} [{fx:?}]: {msg}")); + } else if let Err(e) = check_session(&s) { + failures.push(format!("{p} [{fx:?}]: {e}")); + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + /// Area Type Options with junk fit values, on selected area type (the fixtures have none), with /// text that overflows: never a panic, the document stays sound, and editing still works. #[test] diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 29c9f2449e..d829f72a42 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -21,8 +21,8 @@ pub use edit::{ pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; pub use planar::{ - BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, - live_paint_with_gaps, shape_builder, + BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, + live_paint, live_paint_with_gaps, shape_builder, shape_builder_with_gap, }; /// Errors from fallible operations. diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 548546d8da..39eaddff9c 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -8,11 +8,30 @@ pub use kurbo::{Cap, Join}; use linesweeper::topology::{ContourIdx, Contours}; use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez}; +use crate::edit::remove_redundant_points; /// Flattening/fit tolerance handed to kurbo's stroker. const STROKE_TOL: f64 = 1e-3; +/// Drop collinear and coincident spine vertices before stroking (#3446). Uses `tol` only for +/// redundancy; does not simplify or refit the spine. +fn compact_stroke_spine(bp: &BezPath, tol: f64) -> BezPath { + if bp.elements().is_empty() { + return BezPath::new(); + } + let tol = if tol.is_finite() && tol > 0.0 { tol } else { STROKE_TOL }; + remove_redundant_points(&PathData::from_bezpath(bp), tol).to_bezpath() +} + fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath { + let spine = compact_stroke_spine(bp, STROKE_TOL); + let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0)); + close_all(&kurbo::stroke(spine.iter(), &style, &StrokeOpts::default(), STROKE_TOL)) +} + +/// Like [`stroke_bez`] but without [`compact_stroke_spine`] (regression baseline in unit tests). +#[cfg(test)] +fn stroke_bez_without_spine_compact(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath { let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0)); close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL)) } @@ -217,4 +236,56 @@ mod tests { assert_eq!(out.subpaths.len(), 1); assert!(after > before, "{before} → {after}"); } + + fn outline_max_deviation(a: &BezPath, b: &BezPath) -> f64 { + use kurbo::ParamCurve; + use vectorcraft_geom::Point; + let pa = PathData::from_bezpath(a); + let pb = PathData::from_bezpath(b); + let mut worst: f64 = 0.0; + for (src, dst) in [(a, &pb), (b, &pa)] { + for seg in src.segments() { + for i in 0..=12 { + let p = seg.eval(i as f64 / 12.0); + let pt = Point::new(p.x, p.y); + worst = worst.max(dst.nearest(pt).unwrap().4); + } + } + } + worst + } + + #[test] + fn compact_stroke_spine_drops_colinear_points_without_moving_curved_outlines() { + let width = 10.0; + let cap = Cap::Round; + let join = Join::Round; + let miter = 4.0; + // Dense colinear spine: compact outline should match the uncompacted one closely. + let dense: Vec = (0..=40).map(|i| vectorcraft_geom::Point::new(i as f64 * 2.5, 50.0)).collect(); + let dense_path = PathData::single(SubPath::polyline(&dense, false)); + let dense_bez = dense_path.to_bezpath(); + let compact = stroke_bez(&dense_bez, width, cap, join, miter); + let raw = stroke_bez_without_spine_compact(&dense_bez, width, cap, join, miter); + assert!(outline_max_deviation(&compact, &raw) <= 1e-3, "colinear compaction must not refit the stroke outline"); + let sparse = + PathData::single(SubPath::polyline(&[vectorcraft_geom::Point::new(0.0, 50.0), vectorcraft_geom::Point::new(100.0, 50.0)], false)); + let sparse_outline = outline_stroke(&sparse, width, cap, join, miter); + let compact_region = stroke_region(&[compact]); + assert!( + outline_max_deviation(&compact_region.to_bezpath(), &sparse_outline.to_bezpath()) <= 1e-3, + "compacted colinear spine should outline like the endpoints alone" + ); + // Sampled arc: only redundant vertices are removed; outline must track the raw stroke. + let arc: Vec = (0..=32) + .map(|i| { + let t = i as f64 / 32.0 * std::f64::consts::PI; + vectorcraft_geom::Point::new(50.0 + 50.0 * t.cos(), 50.0 + 8.0 * t.sin()) + }) + .collect(); + let arc_bez = PathData::single(SubPath::polyline(&arc, false)).to_bezpath(); + let arc_compact = stroke_bez(&arc_bez, width, cap, join, miter); + let arc_raw = stroke_bez_without_spine_compact(&arc_bez, width, cap, join, miter); + assert!(outline_max_deviation(&arc_compact, &arc_raw) <= 1e-3, "curved spine compaction must not refit the stroke outline"); + } } diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 83ac9145f2..3836a19656 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -351,6 +351,12 @@ impl FaceMerger { Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines, snap: std::cell::OnceCell::new() } } + /// Merge faces found with Gap Detection using the same temporary bridges as Live Paint. + /// Source indices stay those of the original inputs; bridges have no fill and are never art. + pub fn with_gap(shapes: &[Shape], gap: f64) -> Self { + Self::new(&crate::planar::gap_inputs(shapes, gap)) + } + /// The union of `faces`. pub fn merge(&self, faces: &[&Region]) -> PathData { self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces)) diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index f9f0940b59..beb750a8c7 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -613,12 +613,44 @@ pub fn has_open_path(shapes: &[Shape]) -> bool { /// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas /// count and there are no pieces. pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement { + shape_builder_with_gap(shapes, edges, 0.0) +} + +/// Most distance (document units) a Shape Builder gap may span. +pub const SHAPE_BUILDER_MAX_GAP: f64 = 72.0; + +/// Original inputs and the shared detector's temporary, unfilled bridges. Keep the originals' +/// order and keys so only their split pieces can be returned as art. +pub(crate) fn gap_inputs(shapes: &[Shape], gap: f64) -> Vec { + let mut closed = shapes.to_vec(); + let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); + if segments <= SHAPE_BUILDER_MAX_SEGMENTS && gap.is_finite() && gap > 0.0 && has_open_path(shapes) { + let gaps = find_gaps(shapes, gap.min(SHAPE_BUILDER_MAX_GAP)); + if !gaps.is_empty() { + let path = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect()); + closed.push(Shape::new(path, FillRule::NonZero, u64::MAX)); + } + } + closed +} + +/// [`shape_builder`] with Gap Detection: open paths whose ends come within `gap` (document units) +/// of another path are bridged for the regions they bound, so a region left open by a small gap is +/// found. A `gap` of 0 is [`shape_builder`]. The bridges are in no region's sources and in no piece +/// of `lines` or `edges`. +pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> BuilderArrangement { let has_open = has_open_path(shapes); let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS) .then(|| { let keyed: Vec = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect(); - live_paint(&keyed) + if has_open && gap.is_finite() && gap > 0.0 { + let (faces, pieces) = live_paint(&gap_inputs(&keyed, gap)); + // Gap targets split original outlines too, but the bridges themselves aren't art. + (faces, pieces.into_iter().filter(|p| p.key < shapes.len() as u64).collect()) + } else { + live_paint(&keyed) + } }) .filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty()); let Some((faces, pieces)) = planar else { @@ -1542,6 +1574,78 @@ mod tests { assert_eq!(lines.len(), 9); } + #[test] + fn shape_builder_merges_adjacent_gap_closed_faces_without_keeping_bridges() { + let outline = Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 4.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0), Point::new(0.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + ); + let shapes = [outline, line((50.0, 0.0), (50.0, 100.0), 1)]; + let arr = shape_builder_with_gap(&shapes, true, 6.0); + assert_eq!(arr.regions.len(), 2); + let faces: Vec<_> = arr.regions.iter().collect(); + let merged = crate::FaceMerger::with_gap(&shapes, 6.0).merge(&faces); + assert!((crate::area(&merged, FillRule::NonZero).abs() - 10000.0).abs() < 1e-6); + assert_eq!(merged.subpaths.len(), 1, "the shared edge disappears"); + assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key < 2), "bridges are not art"); + // A request over 72 pt cannot close a gap of 80 pt. + let large = [Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 80.0), Point::new(0.0, 200.0), Point::new(200.0, 200.0), Point::new(200.0, 0.0), Point::new(0.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + )]; + assert!(shape_builder_with_gap(&large, false, 1000.0).regions.is_empty()); + } + + #[test] + fn shape_builder_gap_detection_closes_small_gaps() { + // Three sides of a 100 x 100 square, the fourth open by 4: no region without Gap Detection. + let open = |gap: f64| { + let pts = [(0.0, gap), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)]; + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + vec![Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0)] + }; + let s = open(4.0); + assert!(shape_builder(&s, false).regions.is_empty()); + assert!(shape_builder_with_gap(&s, false, 3.0).regions.is_empty(), "a gap longer than the length stays open"); + let arr = shape_builder_with_gap(&s, true, 6.0); + assert_eq!(arr.regions.len(), 1); + assert!((area(&arr.regions[0]) - 10000.0).abs() < 1e-6); + // The bridge is no art: the path comes back as it was, in its own pieces only. + assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key == 0)); + let len: f64 = arr.lines.iter().map(|p| p.path.to_bezpath().perimeter(1e-6)).sum(); + assert!((len - 396.0).abs() < 1e-6, "{len}"); + // Two paths whose ends miss each other: a U and a lid 3 above it. + let u = Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + ); + let s = [u, line((0.0, -3.0), (100.0, -3.0), 1)]; + assert!(shape_builder_with_gap(&s, false, 2.0).regions.is_empty()); + let arr = shape_builder_with_gap(&s, false, 3.5); + assert_eq!(arr.regions.len(), 1); + assert!((area(&arr.regions[0]) - 10300.0).abs() < 1e-6); + // An end close to the side of another path bridges to the nearest point on it. + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((50.0, 140.0), (50.0, 104.0), 1)]; + assert_eq!(shape_builder_with_gap(&s, false, 5.0).regions.len(), 1, "nothing is enclosed, nothing breaks"); + // Bad gaps are no gap. + let s = open(4.0); + for gap in [f64::NAN, f64::INFINITY, -3.0] { + assert!(shape_builder_with_gap(&s, false, gap).regions.is_empty()); + } + } + #[test] fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() { let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; diff --git a/crates/pathops/tests/ops.rs b/crates/pathops/tests/ops.rs index 52d1af8c8e..871f531ff5 100644 --- a/crates/pathops/tests/ops.rs +++ b/crates/pathops/tests/ops.rs @@ -294,6 +294,20 @@ fn outline_stroke_of_line() { assert!(close(ar(&o), 216.0, 1e-6)); } +#[test] +fn outline_stroke_of_straight_line_with_round_caps_is_compact() { + let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false)); + let o = outline_stroke(&l, 10.0, Cap::Round, Join::Round, 4.0); + assert!(close(ar(&o), 100.0 * 10.0 + PI * 5.0 * 5.0, 1e-3), "{}", ar(&o)); + let anchors = o.subpaths.iter().map(|s| s.anchors.len()).sum::(); + assert!(anchors <= 8, "round-cap expand should stay compact, got {anchors} anchors"); + let dense: Vec = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect(); + let dense_path = PathData::single(SubPath::polyline(&dense, false)); + let dense_o = outline_stroke(&dense_path, 10.0, Cap::Round, Join::Round, 4.0); + let dense_anchors = dense_o.subpaths.iter().map(|s| s.anchors.len()).sum::(); + assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors"); +} + #[test] fn outline_stroke_of_square_is_ring() { let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0); diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs new file mode 100644 index 0000000000..8af4c07d90 --- /dev/null +++ b/crates/render/src/changed.rs @@ -0,0 +1,120 @@ +//! What draws differently after an edit, so a view can draw again only there. + +use std::sync::Arc; + +use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes}; +use vectorcraft_geom::{Affine, Rect}; + +/// What may draw differently in a document than in its version before an edit ([`changed`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Changed { + Nothing, + /// This area of the document, outside of which every pixel draws as before. + Area(Rect), + Everything, +} + +/// What may draw differently in `new` than in `old`, the version before an edit: the bounds of +/// everything the objects that changed paint, before and after the edit ([`crate::painted_bounds`]), +/// or everything when more than the art in the layers changed, or while a pattern is edited (its +/// art also draws in the copies round it). Objects that an edit doesn't touch +/// keep their identity, so this costs what changed, not the size of the document. +pub fn changed(old: &Document, new: &Document) -> Changed { + if new.pattern_edit.is_some() || !old.same_outside_layers(new) { + return Changed::Everything; + } + let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None }; + diff.children(&old.layers, &new.layers); + diff.area.map_or(Changed::Nothing, Changed::Area) +} + +/// The pixels of a `w`×`h` render through `view` that drawing `area` again covers, as x0, y0, x1, +/// y1: its box in the view, grown by a pixel for the anti-aliased edges. None when it covers none. +pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> { + let r = view.transform_rect_bbox(area).inflate(1.0, 1.0); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|c| c.is_finite()) { + return None; + } + let (x0, y0) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0)); + let (x1, y1) = (r.x1.ceil().min(f64::from(w)), r.y1.ceil().min(f64::from(h))); + (x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) +} + +/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far. +struct Diff<'a> { + old: SymbolBoxes<'a>, + new: SymbolBoxes<'a>, + area: Option, +} + +impl Diff<'_> { + /// Members in the same order compare one by one. Members added, removed or restacked count + /// before and after, except those that kept their place unchanged. + fn children(&mut self, old: &[Arc], new: &[Arc]) { + if old.len() == new.len() && old.iter().zip(new).all(|(a, b)| a.id == b.id) { + for (a, b) in old.iter().zip(new) { + self.node(a, b); + } + return; + } + for i in 0..old.len().max(new.len()) { + match (old.get(i), new.get(i)) { + (Some(a), Some(b)) if Arc::ptr_eq(a, b) => {} + (a, b) => { + self.add_old(a.map(|a| &**a)); + self.add_new(b.map(|b| &**b)); + } + } + } + } + + /// A group or layer that only its members changed in, without a clipping mask or effects of + /// its own that draw from all of them, changed where they did. Any other object that changed + /// counts whole, before and after. + fn node(&mut self, old: &Arc, new: &Arc) { + if Arc::ptr_eq(old, new) { + return; + } + if let (Some(a), Some(b)) = (members(old), members(new)) + && same_but_members(old, new) + { + self.children(a, b); + } else { + self.add_old(Some(old)); + self.add_new(Some(new)); + } + } + + fn add_old(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n))); + } + + fn add_new(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n))); + } + + fn add(&mut self, r: Option) { + self.area = vectorcraft_geom::union_opt(self.area, r); + } +} + +/// The members of a group or layer whose members draw each on their own: no clipping mask, and no +/// effects of its own. +fn members(n: &Node) -> Option<&[Arc]> { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !crate::fx::has_object_fx(n) => Some(children), + _ => None, + } +} + +/// Are `a` and `b` the same but for their members? +fn same_but_members(a: &Node, b: &Node) -> bool { + let strip = |n: &Node| { + let mut n = n.clone(); + if let Some(children) = n.children_mut() { + children.clear(); + } + n + }; + strip(a) == strip(b) +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f078d235c5..98a1d6baa5 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -478,12 +478,25 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } _ => { let b = n.visual_bounds_with(symbols)?; + let b = match &n.kind { + NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), + _ => b, + }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) } } } +/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its +/// characters' strokes and by `outset`: on a path they stand above it and run past its ends, in an +/// area they can hang past its frame, where its own box ([`Node::visual_bounds_with`]) doesn't. +fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { + let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds); + let o = outset.max(t.char_stroke_outset()); + (b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o)) +} + /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that /// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the @@ -643,7 +656,7 @@ pub(crate) struct PixelEntry { /// For one cropped to the view, or made at a lower resolution, the view and the rect it was /// made for: it draws only there, so dragging other art over the object keeps it (a wide blur, /// or any blur zoomed in, reaches past the pixel budget). - view: Option, + view: Option, made: Made, /// What the content read when the raster was made, with the channels the effects ran on and, /// under De-Interlace, the raster resolution. @@ -672,13 +685,29 @@ fn linear_key(view: Affine) -> [u64; 4] { [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } -/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a -/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws -/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]). -type ViewKey = ([u64; 6], [u64; 4]); +/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and +/// the device-pixel rect of the frame it was made in ([`PixelEntry::view`], [`ShadowEntry::view`]). +#[derive(Clone, Copy, Debug)] +struct ViewCrop { + pan: Vec2, + screen: Rect, +} -fn view_key(view: Affine, screen: Rect) -> ViewKey { - (view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits)) +impl ViewCrop { + fn of(view: Affine, screen: Rect) -> Self { + Self { pan: view.translation(), screen } + } + + /// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto + /// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies + /// within the one the raster was made in: the same pixels in the same places, as when a frame + /// is drawn again only in a part of it. + fn serves(&self, view: Affine, screen: Rect) -> bool { + let d = view.translation() - self.pan; + let whole = Vec2::new(d.x.round(), d.y.round()); + let s = screen - whole; + (d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1 + } } /// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while @@ -768,7 +797,7 @@ pub(crate) struct ShadowEntry { /// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one /// cropped to the view, or made over such a raster, the view and the rect it was made for: it /// draws only there ([`PixelEntry::view`]). - view: Option, + view: Option, made: Made, /// What the silhouette read when the raster was made. inputs: Inputs, @@ -1084,9 +1113,9 @@ impl Renderer { // What the silhouette reads: as the raster kept for `n` found it, else found now. let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads); let inputs = self.raster_inputs(f, n, reads); - let in_view = view_key(f.view, screen); + let in_view = ViewCrop::of(f.view, screen); let exact = |e: &ShadowEntry| { - e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view) + e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen)) }; if interacting && !self.shadows.get(&key).is_some_and(exact) { self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint); @@ -1784,7 +1813,7 @@ impl Renderer { // The cached raster: of the whole content at full size at this zoom, else of this view. let linear = linear_key(f.view); let whole = fits && k >= 1.0; - let in_view = (!whole).then(|| view_key(f.view, screen)); + let in_view = (!whole).then(|| ViewCrop::of(f.view, screen)); let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; let channels = match f.ink { @@ -1803,8 +1832,12 @@ impl Renderer { self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) }); let interacting = f.opts.interacting; + let in_place = |e: &PixelEntry| match e.view { + None => whole, + Some(v) => !whole && v.serves(f.view, screen), + }; let exact = |e: &PixelEntry| { - e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) + e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) }; if interacting && whole diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f71957999f..fa38b9372f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod brush_fx; +mod changed; pub mod encode; mod freeform; mod fx; @@ -35,6 +36,7 @@ use ink::Ink; use lru::FrameCache; pub use brush_fx::instance_art; +pub use changed::{Changed, changed, view_pixels}; pub use effects::stroke::width_outline; pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget}; pub use live::expand_live; @@ -101,7 +103,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { } /// Rendering options. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct RenderOptions { /// Outline (wireframe) view: 1 px black paths, no paint. pub outline: bool, @@ -444,8 +446,6 @@ pub struct Renderer { text_fonts: u64, /// Clip regions per clipping path (see [`Self::clip_of`]). clips: FrameCache, - /// Context reused when rendering single-threaded (`threads == 0`). - ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). pub threads: u16, geom: PtrMap, @@ -476,7 +476,10 @@ pub struct Renderer { stamp: u64, /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). alpha: f32, - ctx: Option, + /// The contexts kept for the next frames, with the threads they draw on, the last used last: a + /// frame of the size of one reuses it instead of making a context and its threads. Two are + /// kept, so a view's frames and the parts of them drawn again ([`changed`]) each keep theirs. + contexts: Vec<(u16, RenderContext)>, resources: Resources, /// Statistics of the last frame. pub stats: FrameStats, @@ -613,7 +616,6 @@ impl Renderer { texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_fonts: 0, clips: FrameCache::new(lru::CLIP_BUDGET, 256), - ctx_st: None, threads: default_threads(), geom: PtrMap::default(), symbols: vec![], @@ -625,7 +627,7 @@ impl Renderer { fx_geom: PtrMap::default(), stamp: 0, alpha: 1.0, - ctx: None, + contexts: Vec::new(), resources: Resources::new(), stats: FrameStats::default(), brushes: Default::default(), @@ -761,13 +763,13 @@ impl Renderer { /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { let (threads, opts) = (self.threads, f.opts); - let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() }; - let mut ctx = match slot { - Some(mut c) if c.width() == w && c.height() == h => { + let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); + let mut ctx = match kept.map(|i| self.contexts.remove(i)) { + Some((_, mut c)) => { c.reset(); c } - _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), + None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), }; // `reset` keeps the threshold of the previous render: set it every time. ctx.set_aliasing_threshold(opts.anti_alias.threshold()); @@ -800,10 +802,9 @@ impl Renderer { self.draw_page(&mut ctx, f); } let pm = self.finish(&mut ctx, self.raster); - if threads == 0 { - self.ctx_st = Some(ctx); - } else { - self.ctx = Some(ctx); + self.contexts.push((threads, ctx)); + if self.contexts.len() > 2 { + self.contexts.remove(0); } pm.data_as_u8_slice().to_vec() } @@ -2560,6 +2561,8 @@ mod tests_alternate; #[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_changed; +#[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; diff --git a/crates/render/src/tests_changed.rs b/crates/render/src/tests_changed.rs new file mode 100644 index 0000000000..b5e78138f5 --- /dev/null +++ b/crates/render/src/tests_changed.rs @@ -0,0 +1,215 @@ +//! Drawing again only what an edit changed gives the pixels of drawing it all again. + +use super::*; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::Appearance; +use vectorcraft_geom::{Point, shapes}; + +const W: u32 = 160; +const H: u32 = 120; + +/// A view that doesn't line the art up with the pixels, so the edges are anti-aliased. +fn view() -> Affine { + Affine::translate((3.3, 2.7)) * Affine::scale(1.37) +} + +fn rect(d: &mut Document, r: Rect, fill: Color) -> Node { + let id = d.alloc_id(); + Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::solid(Color::BLACK), 2.0)) +} + +fn shadow() -> vectorcraft_doc::Effect { + vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0}), + visible: true, + } +} + +/// A layer with a rectangle, a group at half opacity holding two, a clip group, and one with a +/// drop shadow; the ids of the loose rectangle, a member of each group, and the shadowed one. +fn document() -> (Document, [NodeId; 5]) { + let mut d = Document::new(110.0, 80.0); + let l = d.layers[0].id; + let loose = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + let loose = d.insert(Some(l), 0, loose).unwrap(); + let (a, b) = ( + rect(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Color::rgb(0.0, 0.6, 0.0)), + rect(&mut d, Rect::new(40.0, 15.0, 60.0, 35.0), Color::rgb(0.0, 0.0, 1.0)), + ); + let mut group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(a), Arc::new(b)], clip: false }); + group.opacity = 0.5; + let member = group.children().unwrap()[1].id; + d.insert(Some(l), 1, group).unwrap(); + let (mask, art) = + (rect(&mut d, Rect::new(5.0, 40.0, 30.0, 60.0), Color::WHITE), rect(&mut d, Rect::new(0.0, 45.0, 40.0, 55.0), Color::rgb(0.8, 0.4, 0.0))); + let clipped = art.id; + let clip = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(art)], clip: true }); + d.insert(Some(l), 2, clip).unwrap(); + let mut shadowed = rect(&mut d, Rect::new(60.0, 45.0, 80.0, 60.0), Color::rgb(0.9, 0.9, 0.2)); + shadowed.appearance.effects.push(shadow()); + let shadowed = d.insert(Some(l), 3, shadowed).unwrap(); + let other = rect(&mut d, Rect::new(85.0, 10.0, 105.0, 30.0), Color::rgb(0.5, 0.0, 0.5)); + let other = d.insert(Some(l), 4, other).unwrap(); + (d, [loose, member, clipped, shadowed, other]) +} + +/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that +/// this is `new` drawn whole; returns what changed. +fn redraw(old: &Document, new: &Document) -> Changed { + let mut r = Renderer::new(); + let opts = RenderOptions::default(); + let before = r.render(old, W, H, view(), &opts); + let after = r.render(new, W, H, view(), &opts); + let change = changed(old, new); + let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() }; + match change { + Changed::Everything => patched.pixels = after.pixels.clone(), + Changed::Nothing => {} + Changed::Area(area) => { + if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) { + let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts); + for y in 0..y1 - y0 { + let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize); + let n = ((x1 - x0) * 4) as usize; + patched.pixels[to..to + n].copy_from_slice(&part.pixels[from..from + n]); + } + } + } + } + assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all"); + change +} + +fn area(c: Changed) -> Rect { + match c { + Changed::Area(a) => a, + other => panic!("an area: {other:?}"), + } +} + +#[test] +fn moving_an_object_changes_where_it_was_and_is() { + let (old, [loose, ..]) = document(); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.contains(Point::new(6.0, 6.0)) && a.contains(Point::new(31.0, 22.0)), "before and after: {a:?}"); + assert!(a.x1 < 35.0, "not the objects beside it: {a:?}"); +} + +#[test] +fn recolouring_a_member_of_a_translucent_group_changes_only_the_member() { + let (old, [_, member, ..]) = document(); + let mut new = old.clone(); + new.node_mut(member).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(1.0, 0.5, 0.0)); + let a = area(redraw(&old, &new)); + assert!(a.x0 > 35.0, "not the group's other member: {a:?}"); +} + +#[test] +fn moving_clipped_art_changes_its_whole_clip_group() { + let (old, [_, _, clipped, ..]) = document(); + let mut new = old.clone(); + new.node_mut(clipped).unwrap().transform(Affine::translate((0.0, 4.0)), false); + redraw(&old, &new); +} + +#[test] +fn moving_a_shadowed_object_changes_where_its_shadow_falls() { + let (old, [.., shadowed, _]) = document(); + let mut new = old.clone(); + new.node_mut(shadowed).unwrap().transform(Affine::translate((-5.0, 5.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.x1 > 86.0 && a.y1 > 70.0, "the shadow, before and after: {a:?}"); +} + +#[test] +fn adding_removing_and_restacking_change_those_objects() { + let (old, [loose, _, _, _, other]) = document(); + let mut added = old.clone(); + let n = rect(&mut added, Rect::new(70.0, 5.0, 90.0, 15.0), Color::BLACK); + let l = added.layers[0].id; + added.insert(Some(l), 5, n).unwrap(); + redraw(&old, &added); + let mut removed = old.clone(); + removed.remove(loose).unwrap(); + redraw(&old, &removed); + let mut restacked = old.clone(); + let n = restacked.remove(other).unwrap(); + restacked.insert(Some(l), 0, (*n).clone()).unwrap(); + redraw(&old, &restacked); +} + +/// A blur too wide for a raster of all of it is cropped to the frame. A part of the frame drawn +/// again, during a drag too, draws it from that raster: the same pixels, and no preview at the +/// drag's lower resolution. +#[test] +fn a_part_of_a_frame_draws_a_cropped_blur_from_the_frames_raster() { + let (mut d, _) = document(); + let mut back = rect(&mut d, Rect::new(0.0, 0.0, 110.0, 80.0), Color::rgb(0.1, 0.3, 0.9)); + back.appearance.effects.push(vectorcraft_doc::Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 500.0}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + let view = Affine::scale(2.0) * Affine::translate((-20.0, -15.0)); + let mut r = Renderer::new(); + let whole = r.render(&d, W, H, view, &RenderOptions::default()); + let [x0, y0, x1, y1] = [40, 30, 100, 80]; + let dragging = RenderOptions { interacting: true, dragging: true, ..Default::default() }; + let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &dragging); + assert_eq!(r.stats.previewed, 0, "drawn from the frame's raster"); + for y in 0..y1 - y0 { + for x in 0..x1 - x0 { + assert_eq!(part.pixel(x, y), whole.pixel(x0 + x, y0 + y), "at {x}, {y}"); + } + } +} + +/// Type on a path whose path a script reshapes: its letters stand above the path and run past +/// where it ends, and they change where they were and where they are, outside the path's box. +#[test] +fn reshaping_the_path_of_type_on_a_path_changes_where_its_letters_were_and_are() { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let on = |y: f64| { + let mut t = vectorcraft_doc::text::TextObject::point( + Point::ZERO, + "Text along a path", + vectorcraft_doc::text::CharStyle { size: 14.0, ..Default::default() }, + ); + let line = kurbo::Line::new((10.0, 60.0), (90.0, y)).to_path(0.1); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + // The engine lays out type that changed ([`vectorcraft_doc::TextObject::cached_bounds`]). + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + t + }; + let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(on(55.0))))).unwrap(); + let mut new = old.clone(); + if let Some(NodeKind::Text(t)) = new.node_mut(text).map(|n| &mut n.kind) { + **t = on(25.0); + } + redraw(&old, &new); +} + +#[test] +fn an_unchanged_document_changes_nothing_and_a_new_symbol_everything() { + let (old, _) = document(); + assert_eq!(redraw(&old, &old.clone()), Changed::Nothing); + let mut new = old.clone(); + let art = Arc::new(rect(&mut new, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK)); + new.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art }); + assert_eq!(redraw(&old, &new), Changed::Everything); +} + +/// While a pattern is edited its art also draws in the copies round it: an edit of it changes +/// everything. +#[test] +fn an_edit_while_a_pattern_is_edited_changes_everything() { + let (mut old, [loose, ..]) = document(); + let l = old.layers[0].id; + old.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: l, original: None }); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + assert_eq!(changed(&old, &new), Changed::Everything); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index e4846a431c..50ee506795 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1897,9 +1897,11 @@ impl Writer<'_> { fn text(&mut self, n: &Node, t: &TextObject) { // Live SVG text is written in logical order from left-aligned pieces: bidirectional text // (and right-to-left paragraphs) keep their look as outlines until it is written with - // `direction`/`unicode-bidi`. + // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose + // `direction="rtl"` would anchor their pieces at the right end. let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) { + let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); + if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { self.warn("bidirectional text is outlined to preserve shaping and visual order"); return self.text_outlines(n, t); } @@ -2047,7 +2049,8 @@ impl Writer<'_> { if st.rotation != 0.0 { attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); } - let diff = run_diff(&base.props, self.char_props(st, base.space)); + let mut diff = run_diff(&base.props, self.char_props(st, base.space)); + diff.extend(direction_props(st)); attrs.push_str(&self.attrs(&diff)); let text = xml_text(&seg.text); if attrs.is_empty() && fewer { @@ -2177,7 +2180,8 @@ impl Writer<'_> { let offset = fmt_num(start * 100.0, 3); let mut s = format!(""); for run in &t.runs { - let diff = run_diff(&base, self.char_props(&run.style, space)); + let mut diff = run_diff(&base, self.char_props(&run.style, space)); + diff.extend(direction_props(&run.style)); let a = self.attrs(&diff); s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); } @@ -2210,6 +2214,16 @@ fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) } +/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in +/// its Character Direction, and nothing without one. Each `` of characters with a direction +/// carries them, and the `` does not. Type with a run set Right to Left is written as +/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. +fn direction_props(st: &CharStyle) -> Props { + let Some(d) = st.char_direction else { return Props::new() }; + let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; + vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] +} + /// What the `` of a run whose properties are `run` sets over its ``'s `base`: the /// properties that differ, and the initial value of each one `base` sets and the run doesn't. fn run_diff(base: &Props, run: Props) -> Props { diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index d86403749b..c186347bc9 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -125,6 +125,43 @@ fn proportional_metrics_write_palt() { assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } +/// Character Direction: live text writes each run set Left to Right as a bidi embedding +/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. +#[test] +fn character_direction_is_written_on_its_runs() { + use vectorcraft_doc::ParaDirection; + let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) }; + let d = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr.clone()), run(" ok", style(12.0))], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 3, "{svg}"); + assert!(!tags[0].contains("direction") && !tags[2].contains("direction"), "{svg}"); + assert!(tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{}", tags[1]); + // Type on a path: on the run's `` in the `` too. + let mut on_path = TextObject::point(Point::ZERO, "", style(12.0)); + on_path.runs = vec![run("Due ", style(12.0)), run("21 10 2026", ltr)]; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(on_path))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert!(svg.contains(" { find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) } + /// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges + /// from `r.start` with their direction, in order; neighboring runs of one direction make one + /// stretch. + fn char_directions(&self, r: &Range) -> Vec<(Range, ParaDirection)> { + let mut out: Vec<(Range, ParaDirection)> = Vec::new(); + for (rr, st) in &self.runs { + let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); + let Some(d) = st.char_direction.filter(|_| a < b) else { continue }; + let (a, b) = (a - r.start, b - r.start); + match out.last_mut() { + Some((prev, pd)) if *pd == d && prev.end == a => prev.end = b, + _ => out.push((a..b, d)), + } + } + out + } + /// Shape paragraph `r` (its bidi resolution `bidi`). fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); @@ -1908,7 +1925,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // its first line) follow its own. pen.model = para.leading_model; let text = cx.text; - let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); let rtl = is_rtl(bidi.as_ref()); let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. @@ -2310,7 +2327,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path let mut shaped = Vec::new(); for pr in paras { let text = cx.text; - let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); let glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs)); @@ -2554,6 +2571,52 @@ pub(crate) fn para_bidi(text: &str, direction: Option) -> Option< Some(BidiInfo::new(text, level)) } +/// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not +/// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved +/// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2, +/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own +/// direction is unchanged: the first strong character is found inside embeddings too (P2). +pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option, set: &[(Range, ParaDirection)]) -> Option> { + let plain = para_bidi(text, direction); + if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) { + // A left-to-right embedding in left-to-right text takes an even level: its order is the same. + return plain; + } + // The text with the embedding controls, and where each byte of `text` is in it. + let mut marked = String::with_capacity(text.len() + set.len() * 6); + let mut at_byte: Vec = Vec::with_capacity(text.len()); + let mut copy = |marked: &mut String, from: usize, to: usize| { + if let Some(s) = text.get(from..to) { + at_byte.extend(marked.len()..marked.len() + s.len()); + marked.push_str(s); + } + }; + let mut done = 0; + for (r, d) in set { + let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len())); + if a >= b { + continue; + } + copy(&mut marked, done, a); + marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' }); + copy(&mut marked, a, b); + marked.push('\u{202C}'); + done = b; + } + copy(&mut marked, done, text.len()); + let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); + let info = BidiInfo::new(&marked, level); + let back = |i: usize| at_byte.partition_point(|&m| m < i); + let paragraphs = + info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect(); + Some(BidiInfo { + text, + original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(), + levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(), + paragraphs, + }) +} + /// Does the paragraph run right to left ([`para_bidi`])? pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool { bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl()) diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 868592356c..1fa96c87ae 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -433,9 +433,10 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the -/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning -/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking -/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]). +/// character style's name, leading, baseline shift, rotation, Character Alignment, Character +/// Direction (text is shaped apart where its bidi level changes), the kerning (each glyph takes its +/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard +/// ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. let CharStyle { @@ -469,6 +470,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { position, small_caps, char_align: _, + char_direction: _, proportional_metrics, tate_chu_yoko, digits, diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index c213f09cf9..291b6fa8f3 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -1,8 +1,9 @@ //! Direction, source-cluster and editing regressions; no RTL font assets required. use super::*; use kurbo::{Affine, BezPath, Point, Rect, Shape}; +use std::ops::Range; use std::sync::Arc; -use vectorcraft_doc::{CharStyle, TextKind, TextRun}; +use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextRun}; fn text_layout(s: &str) -> TextLayout { layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default())) @@ -88,6 +89,40 @@ fn mixed_text_preserves_latin_and_number_order() { assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text"); } +/// The characters of `s` in the order a layout of it draws them, left to right, with the bytes +/// `set` given Character Direction `d`. +fn drawn_with_direction(s: &str, set: Range, d: Option) -> String { + let mut t = TextObject::point(Point::ZERO, s, CharStyle::default()); + edit::style_range(&mut t.runs, set.start, set.end, |st| st.char_direction = d); + let l = layout(FontDb::global(), &t); + let mut glyphs: Vec<_> = l.glyphs.iter().collect(); + glyphs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect() +} + +/// Character Direction Left to Right keeps a date written with spaces in its own order inside a +/// right-to-left paragraph, where the bidi algorithm alone sets its numbers right to left. +#[test] +fn left_to_right_characters_keep_a_date_in_order_in_right_to_left_text() { + let s = "שלום 21 10 2026"; + let date = s.find('2').unwrap_or_default()..s.len(); + assert_eq!(drawn_with_direction(s, date.clone(), None), "2026 10 21 םולש"); + assert_eq!(drawn_with_direction(s, date, Some(ParaDirection::LeftToRight)), "21 10 2026 םולש"); +} + +/// Character Direction Right to Left sets a Hebrew word and the exclamation mark after it right to +/// left inside a left-to-right paragraph, where the bidi algorithm alone leaves the mark after the +/// word in the paragraph's direction. +#[test] +fn right_to_left_characters_set_a_word_and_its_mark_right_to_left_in_left_to_right_text() { + let s = "abc שלום! def"; + let word = s.find('ש').unwrap_or_default()..s.find('!').map_or(0, |i| i + 1); + assert_eq!(drawn_with_direction(s, word.clone(), None), "abc םולש! def"); + assert_eq!(drawn_with_direction(s, word.clone(), Some(ParaDirection::RightToLeft)), "abc !םולש def"); + // Left to Right on the same characters of a left-to-right paragraph draws them as without it. + assert_eq!(drawn_with_direction(s, word, Some(ParaDirection::LeftToRight)), "abc םולש! def"); +} + #[test] fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() { let s = "שלום"; diff --git a/crates/text/src/tests_ruby.rs b/crates/text/src/tests_ruby.rs index 2435fa4345..fa49bf13a5 100644 --- a/crates/text/src/tests_ruby.rs +++ b/crates/text/src/tests_ruby.rs @@ -142,12 +142,18 @@ fn vertical_ruby_is_upright_to_the_right_of_the_column() { let ruby = ruby_ink(&lay); assert!(ruby.x0 > base.x0, "ruby right of the column: {ruby:?} vs {base:?}"); assert!(ruby.x0 >= base.x1 - 1.0, "ruby outside the column: {ruby:?} vs {base:?}"); - // Upright kana are taller than wide (they would be lying on their side if not). + // Upright: each reading glyph's ink has the width and height of its outline at 10 pt (half the + // base size); a glyph on its side has them swapped. + let mut not_square = false; for g in &lay.ruby { - if let Some(b) = ink(g) { - assert!(b.width() <= b.height() + 1.0, "upright ruby glyph {b:?}"); - } + let Some(r) = ink(g) else { continue }; + let face = db().face_by_id(g.font_id).expect("the ruby glyph's face"); + let o = db().outline(&face, g.gid).bounding_box(); + let (w, h) = (o.width() * 10.0 / face.units_per_em(), o.height() * 10.0 / face.units_per_em()); + assert!((r.width() - w).abs() < 0.01 && (r.height() - h).abs() < 0.01, "upright ruby glyph {r:?}, its outline {w} x {h}"); + not_square |= (w - h).abs() > 0.5; } + assert!(not_square, "one reading glyph's outline is not square"); // Down the column: the reading's glyphs have distinct y positions. let ys: Vec = lay.ruby.iter().filter_map(|g| ink(g).map(|b| b.center().y)).collect(); assert!(ys.windows(2).any(|w| w[0] != w[1]), "the reading runs down the column: {ys:?}"); diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 5223bb7794..fc4f44d6f0 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -72,7 +72,17 @@ pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { /// The face of `group` under `p` (front-most). pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { - faces(group).into_iter().rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) + front_face(faces(group).into_iter(), p) +} + +/// The face of `group` under `p` (front-most) among its faces that are not hidden. +pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc> { + front_face(faces(group).into_iter().filter(|f| f.visible), p) +} + +/// The face of `faces` (paint order) under `p` (front-most). +fn front_face<'a>(faces: impl DoubleEndedIterator>, p: Point) -> Option<&'a Arc> { + faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) } /// The edge of `group` nearest to `p` within `tol`. @@ -99,11 +109,11 @@ fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, to best.map(|b| b.1) } -/// The visible Live Paint group with a face under `p` (front-most in paint order). +/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { let mut hit = None; doc.walk(|n| { - if is_live_paint(n) && doc.is_visible(n.id) && face_at(n, p).is_some() { + if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { hit = Some(n.id); } }); @@ -243,11 +253,16 @@ impl BuilderMap { /// `open_filled_as_closed` (Consider Open Filled Paths as Closed), a filled open path bounds a /// region as if an invisible edge closed it; without it, the path cuts like a line. pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool) -> (Self, Vec<&'a Node>) { + Self::with_gap(doc, roots, with_edges, open_filled_as_closed, 0.0) + } + + /// The same arrangement with Gap Detection, using the shared Live Paint gap finder. + pub fn with_gap<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool, gap: f64) -> (Self, Vec<&'a Node>) { let (shapes, leaves) = shape_builder_shapes(doc, roots, open_filled_as_closed); if shapes.is_empty() { return (Self::default(), leaves); } - let arr = po::shape_builder(&shapes, with_edges); + let arr = po::shape_builder_with_gap(&shapes, with_edges, gap); let bounded = |pieces: Vec| { pieces .into_iter() @@ -432,6 +447,8 @@ struct RegionCache { live: bool, /// The map has the Shape Builder's outline edges. edges: bool, + /// Gap Detection distance; changing tool options invalidates the cache. + gap: f64, } impl RegionCache { @@ -449,7 +466,7 @@ impl RegionCache { let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) }; BuilderMap { regions, ..Default::default() } } else { - BuilderMap::new(cx.doc, &roots, edges, open_filled_as_closed).0 + BuilderMap::with_gap(cx.doc, &roots, edges, open_filled_as_closed, self.gap).0 }; self.key = Some(key); self.edges = edges; @@ -463,6 +480,10 @@ impl RegionCache { // ---------- Shape Builder ---------- +pub use po::SHAPE_BUILDER_MAX_GAP; +/// Small, Medium and Large gap lengths in points. +pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0]; + /// Shape Builder Tool Options (double-click the tool; `tool.setOption`), kept across tool switches /// and saved with the preferences. #[derive(Clone, Debug, PartialEq)] @@ -486,11 +507,23 @@ pub struct ShapeBuilderOptions { /// Highlight Fill (`highlightFill`, on): in merge mode, the regions under the pointer or /// touched by the drag are shaded with a gray mesh. When it is off, they are not highlighted. pub highlight_fill: bool, + /// Close gaps up to `gap_length` while finding regions. + pub gap_detection: bool, + /// Gap Length in points (at most [`SHAPE_BUILDER_MAX_GAP`]). + pub gap_length: f64, } impl Default for ShapeBuilderOptions { fn default() -> Self { - Self { open_filled_as_closed: true, swatches: false, cursor_swatch_preview: false, straight_line: false, highlight_fill: true } + Self { + open_filled_as_closed: true, + swatches: false, + cursor_swatch_preview: false, + straight_line: false, + highlight_fill: true, + gap_detection: false, + gap_length: GAP_LENGTHS[0], + } } } @@ -550,6 +583,10 @@ pub struct ShapeBuilderTool { const LINE_TOL_PX: f64 = 4.0; impl ShapeBuilderTool { + fn gap(&self) -> f64 { + if self.opts.gap_detection { self.opts.gap_length } else { 0.0 } + } + fn line_tol(cx: &ToolContext, alt: bool) -> Option { alt.then(|| cx.tol(LINE_TOL_PX)) } @@ -655,7 +692,7 @@ impl Tool for ShapeBuilderTool { let pts: Vec = points.iter().map(|q| json!([q.x, q.y])).collect(); // The mode the drag started in, which its highlight showed. let erase = self.alt; - let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase }); + let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase, "gap": self.gap() }); if let Some(t) = Self::line_tol(cx, erase) { params["tolerance"] = json!(t); } @@ -751,6 +788,8 @@ impl Tool for ShapeBuilderTool { "cursorSwatchPreview": o.cursor_swatch_preview, "selection": if o.straight_line { "straightLine" } else { "freeform" }, "highlightFill": o.highlight_fill, + "gapDetection": o.gap_detection, + "gapLength": o.gap_length, }) } @@ -762,8 +801,16 @@ impl Tool for ShapeBuilderTool { "cursorSwatchPreview" => o.cursor_swatch_preview = v.as_bool().unwrap_or(o.cursor_swatch_preview), "selection" => o.straight_line = v.as_str().map_or(o.straight_line, |s| s == "straightLine"), "highlightFill" => o.highlight_fill = v.as_bool().unwrap_or(o.highlight_fill), + "gapDetection" => o.gap_detection = v.as_bool().unwrap_or(o.gap_detection), + "gapLength" => { + if let Some(n) = v.as_f64().filter(|n| n.is_finite()) { + o.gap_length = n.clamp(0.0, SHAPE_BUILDER_MAX_GAP); + } + } _ => {} } + self.cache.key = None; + self.cache.gap = self.gap(); } } @@ -780,14 +827,14 @@ enum LpHover { Region(usize), } -/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a face of a -/// visible Live Paint group ([`live_paint_at`]) it is that group with its face under `p`, or with -/// `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside every face, -/// with `edge`, it is the visible edge nearest `p` within `tol` among the visible Live Paint groups -/// (the front-most group's on a tie). None when no group is found. +/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face +/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under +/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside +/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the +/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { - return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { face_at(g, p) })); + return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); } if !edge { return None; @@ -1417,6 +1464,49 @@ mod tests { } } + #[test] + fn live_paint_tools_skip_hidden_faces() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0)); + let face = |g: NodeId| faces(d.node(g).unwrap())[0].id; + let (back_face, hidden) = (face(back), face(front)); + // The front group's face is hidden on its own; both groups stay visible. + d.node_mut(hidden).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let part = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box()), + _ => panic!("{ov:?}"), + }; + // Where the faces overlap, both tools take the back group's face under the hidden one. + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]); + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); + // Over the hidden face alone, neither tool highlights or picks a face. + bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), None); + assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty()); + sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), None); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + // Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side + // as it does outside every face. + sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0))); + let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); + assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]); + } + #[test] fn live_paint_selection_options_set_the_highlight() { let (d, ..) = live_group_doc(); diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 1cbdbeb13f..8abb10f444 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -42,7 +42,11 @@ const ROWS: &[Row] = &[ (&["star"], None, &["points"]), (&["freeTransform"], None, &["constrain"]), // Shape Builder Tool Options (`builder::ShapeBuilderOptions`). - (&["shapeBuilder"], None, &["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill"]), + ( + &["shapeBuilder"], + None, + &["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill", "gapDetection", "gapLength"], + ), (&["artboard"], None, &["moveArt", "scaleArt"]), // Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes). ( diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 320dd48a66..2628385c17 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -803,6 +803,18 @@ impl Tool for TypeTool { /// `commitTyping` (the caller commits the engine interaction). fn set_option(&mut self, key: &str, v: &Value) { match key { + // Transient selection transferred between Type and Eyedropper; never a saved setting. + "textSelection" => { + if let (Some(id), Some(a), Some(b)) = ( + v.get("editing").and_then(Value::as_u64), + v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64), + v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64), + ) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b)) + { + self.start_editing(NodeId(id), b); + self.anchor = a; + } + } "prevTool" | "previousTool" => { self.prev_tool = v.as_str().map(String::from); } diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 5356452ed1..62b1b024a5 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -23,12 +23,14 @@ use vectorcraft_geom::Point; use super::paint_owner; use crate::params::color_json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; #[derive(Default)] pub struct EyedropperTool { /// Sampling for the selected gradient stop: the tool to switch back to afterwards. stop: Option, + /// The Type tool's highlighted range while sampling, including its selection overlay. + text_selection: crate::text::TypeTool, /// Brush Options › Key Color (Hue Shift): sample into the dialog, then [`Self::stop`]. brush_key_color: bool, } @@ -125,18 +127,37 @@ impl Tool for EyedropperTool { out } + /// Expose a transient text range alongside gradient-stop and brush-key sampling state. fn options(&self) -> Value { - json!({ "stop": self.stop, "brushKeyColor": self.brush_key_color }) + let selected = self.text_selection.options(); + let mut options = if selected.get("editing").is_some_and(|v| !v.is_null()) { selected } else { json!({}) }; + if let Some(o) = options.as_object_mut() { + o.insert("stop".into(), json!(self.stop)); + o.insert("brushKeyColor".into(), json!(self.brush_key_color)); + } + options } + /// Accept a stop target or the selection transferred from the Type tool. fn set_option(&mut self, key: &str, value: &Value) { match key { "stop" => self.stop = value.as_str().map(str::to_string), "brushKeyColor" => self.brush_key_color = value.as_bool().unwrap_or(false), + "textSelection" => self.text_selection.set_option(key, value), _ => {} } } + /// Drop the range when its text is removed or no longer selected. + fn after_command(&mut self, cx: &ToolContext) -> Vec { + self.text_selection.after_command(cx) + } + + /// Keep the sampled character range highlighted without activating text input. + fn overlays(&self, cx: &ToolContext) -> Vec { + if self.text_selection.options().get("editing").is_some_and(|v| !v.is_null()) { self.text_selection.overlays(cx) } else { vec![] } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::Eyedropper } @@ -148,6 +169,22 @@ mod tests { use crate::testutil::*; use vectorcraft_doc::Selection; + /// Upstream brush-key sampling and highlighted-text transfer keep independent state. + #[test] + fn text_range_and_brush_sampling_options_coexist() { + let mut tool = EyedropperTool::default(); + tool.set_option("textSelection", &json!({"editing": 1, "anchor": 0, "caret": 4})); + tool.set_option("stop", &json!("selection")); + tool.set_option("brushKeyColor", &json!(true)); + let options = tool.options(); + assert_eq!((options["editing"].as_u64(), options["start"].as_u64(), options["end"].as_u64()), (Some(1), Some(0), Some(4))); + assert_eq!(options["stop"], "selection"); + assert_eq!(options["brushKeyColor"], true); + tool.set_option("brushKeyColor", &json!(false)); + assert_eq!(tool.options()["editing"], 1); + assert_eq!(tool.options()["brushKeyColor"], false); + } + #[test] fn click_copies_appearance_shift_samples_color() { let (d, id) = doc_with_rect(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c32645b160..bcfe660cda 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -379,6 +379,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { { upload(app, ui.ctx(), &done.img, done.key.pixel.is_some()); app.canvas.key = Some(done.key); + app.canvas.shown = Some((done.doc, done.opts)); app.perf.render_ms = done.ms; app.canvas.last_ms = done.ms; app.canvas.moved = done.moved > 0; @@ -453,13 +454,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let settling = app.canvas.moved && !key.interacting; let staying = key.interacting && app.canvas.from_worker; let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; - match (&mut app.canvas.worker, heavy) { - (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), + let changed = changed_pixels(app, &key, &doc, &opts, view, w, h); + match (&mut app.canvas.worker, heavy, changed) { + // Only the document changed since the frame shown, in a part of it: that part is + // rendered again, here, into the texture. `last_ms` stays the whole frame's. + (_, _, Some(region)) => { + let t0 = now_ms(); + if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { + let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let color = color_image(&part); + let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; + tex.set_partial([x0 as usize, y0 as usize], color, options); + app.canvas.moved |= app.canvas.renderer.stats.moved > 0; + } + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + } + (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), _ => { let t0 = now_ms(); let img = app.canvas.renderer.render(&doc, w, h, view, &opts); upload(app, ui.ctx(), &img, pixel.is_some()); app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); app.perf.render_ms = now_ms() - t0; app.canvas.last_ms = app.perf.render_ms; app.canvas.moved = app.canvas.renderer.stats.moved > 0; @@ -3127,6 +3145,52 @@ pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImag egui::ColorImage::new(size, pixels) } +/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document +/// changed since the frame shown (its key `key` but for the revision, and its render options +/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it +/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at +/// most half of it. None: render the whole frame. Frames the worker renders stay +/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. +fn changed_pixels( + app: &VectorcraftApp, + key: &CacheKey, + doc: &vectorcraft_doc::Document, + opts: &vectorcraft_render::RenderOptions, + view: Affine, + w: u32, + h: u32, +) -> Option> { + let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?; + // A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds. + if opts.mask_view.is_some() { + return None; + } + let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() }); + if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) { + return None; + } + match vectorcraft_render::changed(shown, doc) { + vectorcraft_render::Changed::Nothing => Some(None), + vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { + None => Some(None), + Some([x0, y0, x1, y1]) => { + let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h)); + (u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1])) + } + }, + vectorcraft_render::Changed::Everything => None, + } +} + +/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a +/// frame rendered again come in few sizes, which keep the contexts the renderer keeps. +fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) { + let step = n.div_ceil(8).max(1); + let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n); + let a = a.min(n - len); + (a, a + len) +} + /// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified. fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) { let color = color_image(img); @@ -4691,6 +4755,69 @@ mod tests { (v, out.textures_delta) } + /// An edit of one object renders the canvas again only where it changed, into its texture; a + /// change of the view renders all of it. + #[test] + fn an_edit_renders_only_its_part_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // Where the canvas texture changed in a frame, and how much of it. + let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); + delta.clear(); + set + }; + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + let (pos, size) = canvas(&app, delta).expect("the edit shows"); + let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap(); + assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}"); + // An edit with other render options than the frame shown's renders all of it. + app.session.prefs.pattern_tile_edge_color = "#ff00ff".into(); + app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it"); + app.run("view.setZoom", json!({"zoom": 2})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); + } + + /// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the + /// mask's coverage wherever its art reaches, past its object's bounds. + #[test] + fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let rect = + |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone(); + let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0)); + app.run("select.set", json!({"ids": [obj, mask]})).unwrap(); + app.run("transparency.makeOpacityMask", json!({})).unwrap(); + app.run("transparency.viewOpacityMask", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (_, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap(); + let (_, mut delta) = frame_output(&mut app, &ctx); + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos); + delta.clear(); + assert_eq!(pos, Some(None), "all of it"); + } + /// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles. #[test] fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() { @@ -5298,7 +5425,7 @@ mod tests { // Deliver the actual replacement and verify that it replaces the cached key. let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, img, ms: 1.0, moved: 0 }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap(); frame(&mut app, size, new_ppp); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); } diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index b02057b401..c25ab52aff 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -633,6 +633,16 @@ mod tests { ] } + #[test] + fn recolor_dialog_keeps_the_default_model_without_an_extra_dropdown() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.recolorDialog", json!({"colors": ["#ff8000"]})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(!d.fields.contains_key("model")); + assert!(apply_params(d).get("model").is_none()); + } + #[test] fn merge_and_separate_rows() { let mut r = rows(); diff --git a/crates/ui-egui/src/dialogs/shape_builder_options.rs b/crates/ui-egui/src/dialogs/shape_builder_options.rs index a3ff038be9..84d4cfbce3 100644 --- a/crates/ui-egui/src/dialogs/shape_builder_options.rs +++ b/crates/ui-egui/src/dialogs/shape_builder_options.rs @@ -18,7 +18,7 @@ pub const KIND: &str = "shapeBuilderOptions"; pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 360.0, ..DialogSpec::FORM }; /// The options the dialog edits. -const KEYS: [&str; 5] = ["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill"]; +const KEYS: [&str; 7] = ["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill", "gapDetection", "gapLength"]; /// Width of the label column. const LABEL_W: f32 = 110.0; @@ -37,6 +37,24 @@ pub fn open(app: &mut VectorcraftApp) -> Value { } fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + form::check(ui, d, "gapDetection", tl!("Gap Detection")); + ui.add_enabled_ui(d.bool("gapDetection"), |ui| { + widgets::label_row(ui, tl!("Gap Length:"), LABEL_W, |ui| { + let lengths = vectorcraft_tools::builder::GAP_LENGTHS; + let len = d.f64("gapLength", lengths[0]); + let current = lengths.iter().position(|n| *n == len).unwrap_or(3); + let labels = [tl!("Small"), tl!("Medium"), tl!("Large"), tl!("Custom")]; + if let Some(i) = widgets::dropdown(ui, "shape-builder-gap", labels[current], &labels, 120.0) { + d.fields.insert("gapLength".into(), json!(lengths.get(i).copied().unwrap_or(9.0))); + } + ui.add_enabled_ui(current == 3, |ui| { + if let Some(n) = widgets::plain_field(ui, "shape-builder-gap-length", len, "pt", 1, 64.0) { + d.fields.insert("gapLength".into(), json!(n.clamp(0.0, vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP))); + } + }); + }); + }); + ui.add_space(8.0); form::check(ui, d, "openFilledAsClosed", tl!("Consider Open Filled Paths as Closed")); ui.add_space(8.0); let sources = [("artwork", tl!("Artwork")), ("swatches", tl!("Color Swatches"))]; @@ -81,6 +99,22 @@ mod tests { crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) } + #[test] + fn gap_options_share_the_existing_dialog_and_custom_is_capped() { + let mut a = app(); + a.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap(); + let text = painted(&mut a); + assert!(text.contains("Gap Detection") && text.contains("Gap Length:"), "{text}"); + let d = a.ui.dialog.as_mut().unwrap(); + d.fields.insert("gapDetection".into(), json!(true)); + d.fields.insert("gapLength".into(), json!(1000)); + super::super::confirm(&mut a).unwrap(); + let opts = a.session.tool_options_of("shapeBuilder"); + assert_eq!(opts["gapLength"], 72.0); + assert_eq!(opts["gapDetection"], true); + assert_eq!(opts["openFilledAsClosed"], true); + } + #[test] fn double_clicking_the_tool_opens_its_options_and_ok_keeps_them() { let mut app = app(); @@ -124,7 +158,7 @@ mod tests { let o = app.session.tool_options_of("shapeBuilder"); assert_eq!( o, - json!({"openFilledAsClosed": false, "pickColorFrom": "swatches", "cursorSwatchPreview": true, "selection": "straightLine", "highlightFill": false}) + json!({"openFilledAsClosed": false, "pickColorFrom": "swatches", "cursorSwatchPreview": true, "selection": "straightLine", "highlightFill": false, "gapDetection": false, "gapLength": 3.0}) ); app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap(); let d = app.ui.dialog.as_ref().unwrap(); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 450ef2bc04..5a0565b4e3 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -123,16 +123,18 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s if resp.clicked() { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } - crate::panel_docking::legacy_tab(app, ui, id, &resp); + crate::panel_docking::legacy_tab(app, ui, id, &resp, false); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + crate::panel_docking::prune_collapsed(&mut app.ui); if crate::panel_docking::show(app, ui) { return; } apply_pending_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL, clamp_legacy_dock_width); let t = Tokens::get(ui.ctx()); let shown = floating::shown_tab(&app.ui); + ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), Vec::::new())); // Main tabbed group, unless it is collapsed to icons or all its panels float. let group = shown.filter(|_| !app.ui.dock_collapsed).map(|active| { egui::Panel::right(LEGACY_DOCK_PANEL) @@ -145,10 +147,23 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if collapse_header(ui, false) { toggle(app, true); } + // What a group dropped on it joins ([`floating::Drop::Root`]). + let rect = ui.available_rect_before_wrap(); + let group = floating::RootGroup { + panels: floating::docked_tabs(&app.ui).map(|tab| tab.info().0).collect(), + shown: active.info().0, + rect, + strip: Rect::from_min_size(rect.min, vec2(rect.width(), 33.0)), + }; + ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), vec![group])); if tab_strip(app, ui, active) { // It floats from this frame on. return; } + // Collapsed to its tabs (a double-click on one). + if app.ui.docking_collapsed.contains(app.ui.dock_tab.info().0) { + return; + } egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match active { DockTab::Properties => { egui::ScrollArea::vertical() @@ -220,9 +235,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } -/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a tab dragged out of -/// the strip floats on its own, and the strip right of the tabs floats the whole group. True when -/// the shown panel floats now. +/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a double-click +/// collapses the group to its tabs or expands it, a tab dragged out of the strip floats on its own, +/// and the strip right of the tabs floats the whole group. True when the shown panel floats now. fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let t = Tokens::get(ui.ctx()); let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); @@ -248,10 +263,14 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let id = tab.info().0; if let Some((from, to)) = tab_reorder_on_release(strip, pointer, resp, &out.responses) { apply_dock_tab_reorder(app, &tabs, from, to); + } else if resp.double_clicked() { + if let Err(error) = app.run("window.panel.collapse", json!({ "panel": id })) { + app.ui.status = error; + } } else if resp.clicked() { app.ui.dock_tab = tab; } - crate::panel_docking::legacy_tab_in_strip(app, ui, id, resp, Some(strip)); + crate::panel_docking::legacy_tab_in_strip(app, ui, id, resp, true, Some(strip)); } if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() { app.ui.dock_tab = tab; @@ -398,8 +417,10 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { return; }; let tab = DockTab::from_id(id); - // Expanded, the group shows its panels in the dock itself; a floating panel shows in its group. - if tab.is_some() && !app.ui.dock_collapsed || floating::group_of(&app.ui, id).is_some() { + // Expanded, the group shows its panels in the dock itself, unless one was dropped on the icon + // column; a floating panel shows in its group. + let in_rail = app.ui.docking_icons.contains(id) && app.ui.docking.as_ref().is_some_and(|layout| !layout.contains(&id.to_string())); + if tab.is_some() && !app.ui.dock_collapsed && !in_rail || floating::group_of(&app.ui, id).is_some() { return; } // A library panel (in a collapsed dock) is titled by its library's name. diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 2a4b87e6d6..8daa6e890a 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -3,10 +3,12 @@ //! A dock tab, an icon of the icon column or a popped-out panel's title dragged out of the dock //! floats that panel; the strip right of the dock's tabs floats the whole tabbed group. A floating //! group moves by its title bar (or the strip right of its tabs, or the tab of a lone panel), and a -//! tab dragged out of a group floats on its own. Dropped on the dock, which lights up while the -//! pointer is over it, a group's panels go back to where they live in the dock (the tabbed group or -//! the icon column); dropped on another group's title bar or tabs, they stack with that group. The -//! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its +//! tab dragged out of a group floats on its own. Dropped on the tabs of one of the dock's groups +//! (lit up), a group's panels join that group as tabs; dropped on the upper or lower half of it, +//! they make a new group above or below it in the dock. Dropped on the icon column, which lights +//! up, they become icons there, the tabbed group's panels too; dropped anywhere else on the dock, +//! they go back to where they live in it. Dropped on another floating group's title bar or tabs, +//! they stack with that group. The × of a group puts its panels back in the dock too. The Tools panel floats the same way by its //! title bar ([`crate::toolbar`]) and docks again on the window's left edge. //! //! Dropped on the upper or lower half of another group (a line shows where), a group joins it in @@ -80,6 +82,47 @@ pub(crate) enum Drop { Above(&'static str), /// In the dock as a new column at this place (1: next to the icon column, counting left). Column(u32), + /// In the dock's group showing this panel: as tabs, or as a new group above or below it. + Root(&'static str, RootZone), + /// In the icon column, as icons (`window.panel.dock {icon: true}`). + Icons, +} + +/// Where a drop on one of the dock's groups puts the moved panels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RootZone { + /// With the group, as tabs. + Tab, + /// In a new group right above it. + Top, + /// In a new group right below it. + Bottom, +} + +impl RootZone { + /// The `zone` of `window.panel.dock` / `window.panel.move`. + pub(crate) fn id(self) -> &'static str { + match self { + RootZone::Tab => "tab", + RootZone::Top => "top", + RootZone::Bottom => "bottom", + } + } +} + +/// One of the dock's groups as last drawn (egui temp memory under [`root_groups_id`]): its panels, +/// the one it shows, its bounds and its tab strip. A group dropped on it joins it ([`Drop::Root`]). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RootGroup { + pub panels: Vec<&'static str>, + pub shown: &'static str, + pub rect: Rect, + pub strip: Rect, +} + +/// The dock's groups as last drawn, top to bottom. +pub(crate) fn root_groups_id() -> Id { + Id::new("floating-root-groups") } fn move_id() -> Id { @@ -459,7 +502,31 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin let y = ctx.data(|d| d.get_temp::(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0); return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0)))); } - let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?; + let shown = app.ui.dock && app.ui.screen_mode < 3; + // The icon column takes the panels as icons. (Mid-frame the dock's rect may not take it in yet.) + if let Some(icons) = zone(icons_rect_id()).filter(|_| shown) { + return Some((Drop::Icons, icons)); + } + let dock = zone(dock_rect_id()).filter(|_| shown)?; + // One of the dock's groups: its tabs take the panels as tabs, its upper or lower half puts them + // in a new group above or below it. The group they came from (being dragged by a tab) takes + // nothing: the shared dock handles a tab moved within it. + let moved: Vec<&str> = std::iter::once(id).chain(ids_in(&app.ui, &moving)).collect(); + let groups = ctx.data(|d| d.get_temp::>(root_groups_id())).unwrap_or_default(); + if let Some(group) = groups.iter().find(|g| g.rect.contains(at)) { + let anchor = Some(group.shown).filter(|p| !moved.contains(p)).or_else(|| group.panels.iter().copied().find(|p| !moved.contains(p))); + if let Some(anchor) = anchor { + if group.strip.contains(at) { + return Some((Drop::Root(anchor, RootZone::Tab), group.strip)); + } + let mid = group.rect.center().y; + return Some(if at.y < mid { + (Drop::Root(anchor, RootZone::Top), Rect::from_x_y_ranges(group.rect.x_range(), group.rect.top()..=mid)) + } else { + (Drop::Root(anchor, RootZone::Bottom), Rect::from_x_y_ranges(group.rect.x_range(), mid..=group.rect.bottom())) + }); + } + } // Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to // their icons: light up the icon column, where they land, rather than the tabs. let tabbed = !app.ui.dock_collapsed @@ -492,8 +559,8 @@ fn insertion_line(ctx: &egui::Context, line: Rect) { pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) { match target { Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect), - Drop::Stack(_) => tab_zone(ctx, rect), - Drop::Dock => highlight(ctx, rect), + Drop::Stack(_) | Drop::Root(..) => tab_zone(ctx, rect), + Drop::Dock | Drop::Icons => highlight(ctx, rect), } } @@ -516,6 +583,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.docking = m.original.docking; app.ui.docking_hidden = m.original.docking_hidden; app.ui.docking_icons = m.original.docking_icons; + app.ui.docking_collapsed = m.original.docking_collapsed; app.ui.floating_panels = m.original.floating_panels; app.ui.open_panel = m.original.open_panel; app.ui.dock_tab = m.original.dock_tab; @@ -548,7 +616,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } match target { Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), - Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs), + Some((Drop::Stack(_) | Drop::Root(..), tabs)) => tab_zone(ctx, tabs), Some((_, zone)) => highlight(ctx, zone), None => {} } @@ -565,7 +633,30 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { let set = set_of(&app.ui, gi); let ids = ids_in(&app.ui, &set); let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or(id); + // Icon panels dropped on the icon column go back to their icons, as on the rest of the dock. + let drop = match drop { + Drop::Icons if ids.iter().all(|p| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == *p)) => Drop::Dock, + drop => drop, + }; + if matches!(drop, Drop::Root(..) | Drop::Icons) { + // The shared layout puts them in the dock's tree, group by group: the first where it was + // dropped, the others with it as tabs. + let leads: Vec<&'static str> = set.iter().filter_map(|&i| shown(&app.ui, i)).collect(); + for (k, lead) in leads.iter().enumerate() { + let params = match (drop, leads.first()) { + (Drop::Root(anchor, zone), _) if k == 0 => json!({"panel": lead, "group": true, "anchor": anchor, "zone": zone.id()}), + (Drop::Root(..), Some(first)) => json!({"panel": lead, "group": true, "anchor": first, "zone": "tab"}), + _ => json!({"panel": lead, "group": true, "icon": true}), + }; + if let Err(error) = app.run("window.panel.dock", params) { + app.ui.status = error; + break; + } + } + return; + } match drop { + Drop::Root(..) | Drop::Icons => {} Drop::Dock => { dock(&mut app.ui, &ids, active); // One that went back to its icon pops out of it, so it doesn't seem to vanish. @@ -1384,24 +1475,55 @@ mod tests { assert_eq!(h.dock_tabs().len(), 4); } + /// A floating icon panel dropped on the icon column lights it up, goes back and pops out of its icon. #[test] - fn a_panel_dropped_on_the_dock_lights_up_and_pops_out_of_its_icon() { + fn a_panel_dropped_on_the_icon_column_lights_it_up_and_pops_out_of_its_icon() { let mut h = Harness::new(); let count = h.icons().len(); h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); assert_eq!(h.groups(), [["color"]]); - // Over the tabbed group: Color has no tab there, so the icon column lights up, not the tabs. let (dock, column) = (h.temp_rect(dock_rect_id()), h.temp_rect(icons_rect_id())); - let over = h.dock_tabs()[1].center(); + let over = column.center(); h.hold(h.title("color"), over); - assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Dock, column))); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Icons, column))); assert!(column.width() < dock.width(), "the column, not the whole dock: {column:?} {dock:?}"); // Dropped, it's back in the column and pops out of its icon rather than vanishing. h.release(over); - assert!(h.groups().is_empty()); + assert!(h.groups().is_empty(), "{}", h.app.ui.status); assert_eq!((h.icons().len(), h.app.ui.open_panel.as_deref()), (count, Some("color"))); } + /// A floating panel dropped on the dock's tabs joins them; on the group's lower half it makes a group below. + #[test] + fn a_panel_dropped_on_the_dock_tabs_joins_them_and_on_its_lower_half_makes_a_group_below() { + let mut h = Harness::new(); + h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); + assert_eq!(h.groups(), [["color"]]); + // Over the tabbed group's tabs: they light up, and Color joins them. + let over = h.dock_tabs()[1].center(); + h.hold(h.title("color"), over); + assert!(matches!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Root(_, RootZone::Tab), _)))); + h.release(over); + assert!(h.groups().is_empty()); + assert_eq!(h.dock_tabs().len(), 4); + assert!(h.app.ui.open_panel.is_none()); + // Color Guide on the group's lower half: a group of its own below the tabbed group. + h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); + assert_eq!(h.groups(), [["colorGuide"]]); + let dock = h.ctx.data(|d| d.get_temp::>(root_groups_id())).unwrap()[0].rect; + let lower = pos2(dock.center().x, dock.bottom() - 60.0); + h.hold(h.title("colorGuide"), lower); + assert!(matches!(drop_target(&h.app, &h.ctx, Moving::Panel("colorGuide"), lower), Some((Drop::Root(_, RootZone::Bottom), _)))); + h.release(lower); + assert!(h.groups().is_empty()); + let groups = h.ctx.data(|d| d.get_temp::>(root_groups_id())).unwrap(); + assert_eq!( + groups.iter().map(|g| g.panels.clone()).collect::>(), + [vec!["properties", "layers", "libraries", "color"], vec!["colorGuide"]] + ); + assert!(groups[0].rect.bottom() <= groups[1].rect.top() + 1.0); + } + #[test] fn a_popped_out_panel_dragged_by_its_title_floats() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0d63a0eeb5..908903ff47 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2342,6 +2342,7 @@ Proof Setup Настройка на пробен печат Properties Свойства Proportional Metrics Пропорционални метрики + Character Direction Посока на знаците Digits Цифри Arabic Арабски Hindi Хинди @@ -4474,6 +4475,7 @@ effect Soft Меко Activate Panel Активиране на панела Change Panel Layout Промяна на оформлението на панела Close Panel Затваряне на панела + Collapse Panel Group Свиване на групата панели Move Panel Преместване на панела Group with Групиране с Resize panels Преоразмеряване на панели @@ -4484,6 +4486,12 @@ effect Soft Меко @msg before must name a panel параметърът „преди“ трябва да указва панел @msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне @msg group must be true or false групата трябва да е истина или лъжа +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панелът е плаващ; свийте групата му с window.panel.float {panel, collapsed} +@msg panel is not in a dock group панелът не е в група на дока +@msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга +@msg icon must be true or false icon трябва да е истина или лъжа +@msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor +@msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon @msg move requires a destination panel преместването изисква целеви панел @msg move requires an existing destination panel преместването изисква съществуващ целеви панел @msg onto must name a floating panel параметърът „върху“ трябва да указва плаващ панел @@ -5285,6 +5293,7 @@ theme Appearance Външен вид Persistent Постоянна Locate Library… Намиране на библиотека… Library Panel Options Опции на панела с библиотека + Gap Length: Дължина на пропуска: Artboard {n} of {total} Работна област {n} от {total} Encoding and writing the file… Кодиране и записване на файла… @plural {n} more export waiting|{n} more exports waiting Още {n} експортиране в изчакване|Още {n} експортирания в изчакване @@ -5326,5 +5335,11 @@ theme Appearance Външен вид Water Paper Мокра хартия Ruby… Руби… Ruby Руби + Ease In Плавно начало + Ease Out Плавен край + Ease In Out Плавно начало и край + Ease Out In Плавен край и начало + Ramp: Рампа: + Shift: Изместване: Fresco… Фреска… Fresco Фреска diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index edfd0acffa..64d4314e47 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -829,6 +829,7 @@ Activate Panel Aktivovat panel Change Panel Layout Změnit rozložení panelů Close Panel Zavřít panel + Collapse Panel Group Sbalit skupinu panelů Move Panel Přesunout panel Group with Seskupit s Resize panels Změnit velikost panelů @@ -1393,6 +1394,7 @@ lighting Light Světlo Leading Proklad No Break Nezalamovat Proportional Metrics Proporcionální metrika + Character Direction Směr znaků Digits Číslice Arabic Arabské Hindi Hindské @@ -4752,6 +4754,12 @@ imageTrace Outline Obrys @msg before must name a panel before musí určovat panel @msg docking action contains an unknown panel or an excessive split path akce ukotvení obsahuje neznámý panel nebo příliš dlouhou cestu rozdělení @msg group must be true or false group musí být true nebo false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel je plovoucí; jeho skupinu sbalíte pomocí window.panel.float {panel, collapsed} +@msg panel is not in a dock group panel není ve skupině doku +@msg only a dock group above or below others collapses, not one beside another sbalit lze jen skupinu doku nad nebo pod jinými, ne vedle jiné +@msg icon must be true or false icon musí být true nebo false +@msg icon only docks a panel in the icon column, with no anchor icon jen ukotví panel ve sloupci ikon, bez anchor +@msg column docks a set as a dock column, with no anchor or icon column ukotví sadu jako sloupec doku, bez anchor nebo icon @msg move requires a destination panel přesun vyžaduje cílový panel @msg move requires an existing destination panel přesun vyžaduje existující cílový panel @msg onto must name a floating panel onto musí určovat plovoucí panel @@ -5328,6 +5336,7 @@ mark-style Japanese Japonské @msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů). + Gap Length: Délka mezery: Artboard {n} of {total} Pracovní plocha {n} z {total} Encoding and writing the file… Kódování a zápis souboru… @plural {n} more export waiting|{n} more exports waiting Čeká ještě {n} export|Čekají ještě {n} exporty|Čeká ještě {n} exportů @@ -5369,5 +5378,11 @@ mark-style Japanese Japonské Water Paper Mokrý papír Ruby… Ruby… Ruby Ruby + Ease In Pozvolný začátek + Ease Out Pozvolný konec + Ease In Out Pozvolný začátek a konec + Ease Out In Pozvolný konec a začátek + Ramp: Síla: + Shift: Posun: Fresco… Freska… Fresco Freska diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index a98ce70717..6191e41483 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -761,6 +761,7 @@ Activate Panel Aktivér panel Change Panel Layout Skift panelets layout Close Panel Luk panel + Collapse Panel Group Skjul panelgruppe Move Panel Flyt panel Group with Gruppér med Resize panels Tilpas størrelse på paneler @@ -1311,6 +1312,7 @@ lighting Light Lys Leading Linjeafstand No Break Ingen ombrydning Proportional Metrics Proportionale metrikker + Character Direction Tegnretning Digits Cifre Arabic Arabisk Hindi Hindi @@ -4450,6 +4452,12 @@ imageTrace Outline Kontur @msg before must name a panel before skal angive et panel @msg docking action contains an unknown panel or an excessive split path dokningshandlingen indeholder et ukendt panel eller en overdreven opdelingssti @msg group must be true or false group skal være true eller false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panelet flyder; skjul dets gruppe med window.panel.float {panel, collapsed} +@msg panel is not in a dock group panelet er ikke i en dockgruppe +@msg only a dock group above or below others collapses, not one beside another kun en dockgruppe over eller under andre kan skjules, ikke en ved siden af en anden +@msg icon must be true or false icon skal være true eller false +@msg icon only docks a panel in the icon column, with no anchor icon docker kun et panel i ikonkolonnen, uden anchor +@msg column docks a set as a dock column, with no anchor or icon column docker et sæt som en dockkolonne, uden anchor eller icon @msg move requires a destination panel move kræver et destinationspanel @msg move requires an existing destination panel move kræver et eksisterende destinationspanel @msg onto must name a floating panel onto skal angive et flydende panel @@ -5281,6 +5289,7 @@ swatch Bright Violet Kraftig violet Persistent Vedvarende Locate Library… Lokalisér bibliotek… Library Panel Options Indstillinger for bibliotekspanel + Gap Length: Hullængde: Artboard {n} of {total} Tegneflade {n} af {total} Encoding and writing the file… Koder og skriver filen… @plural {n} more export waiting|{n} more exports waiting {n} eksport mere venter|{n} eksporter mere venter @@ -5342,5 +5351,11 @@ swatch Bright Violet Kraftig violet Water Paper Vådt papir Ruby… Ruby… Ruby Ruby + Ease In Langsom start + Ease Out Langsom slutning + Ease In Out Langsom start og slutning + Ease Out In Langsom slutning og start + Ramp: Styrke: + Shift: Forskydning: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 352b09e402..fa876a695d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2360,6 +2360,7 @@ mark-style Japanese Japanisch Proof Setup Proof einrichten Properties Eigenschaften Proportional Metrics Proportionale Metrik + Character Direction Zeichenrichtung Digits Ziffern Arabic Arabisch Hindi Hindi @@ -4554,6 +4555,7 @@ theme Appearance Erscheinungsbild Activate Panel Bedienfeld aktivieren Change Panel Layout Bedienfeldanordnung ändern Close Panel Bedienfeld schließen + Collapse Panel Group Bedienfeldgruppe minimieren Move Panel Bedienfeld verschieben Group with Gruppieren mit Resize panel window Bedienfeldfenster skalieren @@ -4564,6 +4566,12 @@ theme Appearance Erscheinungsbild @msg before must name a panel before muss ein Bedienfeld benennen @msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad @msg group must be true or false group muss true oder false sein +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Bedienfeld ist abgelöst; seine Gruppe mit window.panel.float {panel, collapsed} minimieren +@msg panel is not in a dock group Bedienfeld ist in keiner Dock-Gruppe +@msg only a dock group above or below others collapses, not one beside another nur eine Dock-Gruppe über oder unter anderen lässt sich minimieren, nicht eine daneben +@msg icon must be true or false icon muss true oder false sein +@msg icon only docks a panel in the icon column, with no anchor icon dockt ein Bedienfeld nur in der Symbolspalte an, ohne anchor +@msg column docks a set as a dock column, with no anchor or icon column dockt einen Satz als Dock-Spalte an, ohne anchor oder icon @msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld @msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld @msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen @@ -4908,6 +4916,7 @@ effect Soft Weich @msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel). + Gap Length: Lückenlänge: Artboard {n} of {total} Zeichenfläche {n} von {total} Encoding and writing the file… Datei wird codiert und geschrieben… @plural {n} more export waiting|{n} more exports waiting {n} weiterer Export wartet|{n} weitere Exporte warten @@ -4950,5 +4959,11 @@ effect Soft Weich Water Paper Feuchtes Papier Ruby… Ruby… Ruby Ruby + Ease In Langsam starten + Ease Out Langsam enden + Ease In Out Langsam starten und enden + Ease Out In Langsam enden und starten + Ramp: Stärke: + Shift: Verschiebung: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9132dd43c1..d2a9eecdb3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1738,6 +1738,7 @@ Proof Colors Χρώματα δοκιμίου Proof Setup Ρύθμιση δοκιμίου Properties Ιδιότητες + Character Direction Κατεύθυνση χαρακτήρων Digits Ψηφία Punctuation Στίξη Puppet Warp Παραμόρφωση με σημεία ελέγχου @@ -2229,6 +2230,7 @@ effect Soft Απαλό Activate Panel Ενεργοποίηση πάνελ Change Panel Layout Αλλαγή διάταξης πάνελ Close Panel Κλείσιμο πάνελ + Collapse Panel Group Σύμπτυξη ομάδας πάνελ Move Panel Μετακίνηση πάνελ Group with Ομαδοποίηση με Resize panels Αλλαγή μεγέθους πάνελ @@ -4469,6 +4471,12 @@ theme Appearance Εμφάνιση @msg before must name a panel το before πρέπει να ορίζει πίνακα @msg docking action contains an unknown panel or an excessive split path η ενέργεια σύνδεσης περιέχει άγνωστο πίνακα ή υπερβολικά βαθιά διαδρομή διαίρεσης @msg group must be true or false το group πρέπει να είναι true ή false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Το πάνελ αιωρείται· συμπτύξτε την ομάδα του με window.panel.float {panel, collapsed} +@msg panel is not in a dock group Το πάνελ δεν βρίσκεται σε ομάδα αγκύρωσης +@msg only a dock group above or below others collapses, not one beside another συμπτύσσεται μόνο ομάδα αγκύρωσης πάνω ή κάτω από άλλες, όχι δίπλα σε άλλη +@msg icon must be true or false το icon πρέπει να είναι true ή false +@msg icon only docks a panel in the icon column, with no anchor το icon αγκυρώνει πάνελ μόνο στη στήλη εικονιδίων, χωρίς anchor +@msg column docks a set as a dock column, with no anchor or icon το column αγκυρώνει ένα σύνολο ως στήλη αγκύρωσης, χωρίς anchor ή icon @msg move requires a destination panel η μετακίνηση απαιτεί πίνακα προορισμού @msg move requires an existing destination panel η μετακίνηση απαιτεί υπάρχοντα πίνακα προορισμού @msg onto must name a floating panel το onto πρέπει να ορίζει αιωρούμενο πίνακα @@ -5280,6 +5288,7 @@ effect Points Σημεία @msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel). + Gap Length: Μήκος κενού: Artboard {n} of {total} Πίνακας σχεδίασης {n} από {total} Encoding and writing the file… Κωδικοποίηση και εγγραφή του αρχείου… @plural {n} more export waiting|{n} more exports waiting {n} ακόμη εξαγωγή σε αναμονή|{n} ακόμη εξαγωγές σε αναμονή @@ -5321,5 +5330,11 @@ effect Points Σημεία Water Paper Βρεγμένο χαρτί Ruby… Ruby… Ruby Ruby + Ease In Ομαλή αρχή + Ease Out Ομαλό τέλος + Ease In Out Ομαλή αρχή και τέλος + Ease Out In Ομαλό τέλος και αρχή + Ramp: Ένταση: + Shift: Μετατόπιση: Fresco… Νωπογραφία… Fresco Νωπογραφία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d0841c3dfc..b5bf466171 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2394,6 +2394,7 @@ Proof Setup Ajuste de prueba Properties Propiedades Proportional Metrics Métrica proporcional + Character Direction Dirección de caracteres Digits Dígitos Arabic Árabes Hindi Hindi @@ -4557,6 +4558,7 @@ axis Both Ambos Activate Panel Activar panel Change Panel Layout Cambiar distribución de paneles Close Panel Cerrar panel + Collapse Panel Group Contraer grupo de paneles Move Panel Mover panel Group with Agrupar con Resize panels Cambiar tamaño de los paneles @@ -4567,6 +4569,12 @@ axis Both Ambos @msg before must name a panel before debe indicar un panel @msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga @msg group must be true or false group debe ser true o false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} el panel flota; contraiga su grupo con window.panel.float {panel, collapsed} +@msg panel is not in a dock group el panel no está en un grupo del acoplamiento +@msg only a dock group above or below others collapses, not one beside another solo se contrae un grupo del acoplamiento situado encima o debajo de otros, no al lado +@msg icon must be true or false icon debe ser true o false +@msg icon only docks a panel in the icon column, with no anchor icon solo acopla un panel en la columna de iconos, sin anchor +@msg column docks a set as a dock column, with no anchor or icon column acopla un conjunto como columna del acoplamiento, sin anchor ni icon @msg move requires a destination panel Para mover se requiere un panel de destino @msg move requires an existing destination panel Para mover se requiere un panel de destino existente @msg onto must name a floating panel onto debe indicar un panel flotante @@ -5270,6 +5278,7 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo). + Gap Length: Longitud del hueco: Artboard {n} of {total} Mesa de trabajo {n} de {total} Encoding and writing the file… Codificando y escribiendo el archivo… @plural {n} more export waiting|{n} more exports waiting {n} exportación más en espera|{n} exportaciones más en espera @@ -5311,5 +5320,11 @@ mark-style Japanese Japonesas Water Paper Papel mojado Ruby… Ruby… Ruby Ruby + Ease In Entrada suave + Ease Out Salida suave + Ease In Out Entrada y salida suaves + Ease Out In Salida y entrada suaves + Ramp: Intensidad: + Shift: Desplazamiento: Fresco… Fresco… Fresco Fresco diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 97ba837cd1..de2b239cb2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -812,6 +812,7 @@ Activate Panel Aktivoi paneeli Change Panel Layout Muuta paneelien asettelua Close Panel Sulje paneeli + Collapse Panel Group Kutista paneeliryhmä Move Panel Siirrä paneelia Group with Ryhmitä kohteen kanssa Resize panels Muuta paneelien kokoa @@ -1368,6 +1369,7 @@ lighting Light Valo Leading Riviväli No Break Ei katkoa Proportional Metrics Suhteelliset metriikat + Character Direction Merkkien suunta Digits Numerot Arabic Arabia Hindi Hindi @@ -4661,6 +4663,12 @@ imageTrace Outline Ääriviiva @msg before must name a panel before-arvon on nimettävä paneeli @msg docking action contains an unknown panel or an excessive split path telakointitoiminto sisältää tuntemattoman paneelin tai liian pitkän jakopolun @msg group must be true or false group-arvon on oltava true tai false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} paneeli kelluu; kutista sen ryhmä komennolla window.panel.float {panel, collapsed} +@msg panel is not in a dock group paneeli ei ole telakkaryhmässä +@msg only a dock group above or below others collapses, not one beside another vain muiden ylä- tai alapuolella oleva telakkaryhmä kutistuu, ei toisen vieressä oleva +@msg icon must be true or false icon-arvon on oltava true tai false +@msg icon only docks a panel in the icon column, with no anchor icon telakoi paneelin vain kuvakesarakkeeseen, ilman anchor-arvoa +@msg column docks a set as a dock column, with no anchor or icon column telakoi joukon telakkasarakkeeksi, ilman anchor- tai icon-arvoa @msg move requires a destination panel siirto vaatii kohdepaneelin @msg move requires an existing destination panel siirto vaatii olemassa olevan kohdepaneelin @msg onto must name a floating panel onto-arvon on nimettävä kelluva paneeli @@ -5292,6 +5300,7 @@ mark-style Japanese Japanilainen @msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä). + Gap Length: Aukon pituus: Artboard {n} of {total} Piirtoalusta {n}/{total} Encoding and writing the file… Tiedostoa koodataan ja kirjoitetaan… @plural {n} more export waiting|{n} more exports waiting {n} vienti odottaa|{n} vientiä odottaa @@ -5400,5 +5409,11 @@ swatch Bright Violet Kirkas violetti Water Paper Märkä paperi Ruby… Ruby… Ruby Ruby + Ease In Hidas alku + Ease Out Hidas loppu + Ease In Out Hidas alku ja loppu + Ease Out In Hidas loppu ja alku + Ramp: Voimakkuus: + Shift: Siirtymä: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 51c06b48b8..c0e8ecc9f9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2391,6 +2391,7 @@ Proof Setup Format d’épreuve Properties Propriétés Proportional Metrics Métrique proportionnelle + Character Direction Direction des caractères Digits Chiffres Arabic Arabes Hindi Hindi @@ -4569,6 +4570,7 @@ effect Soft Doux Activate Panel Activer le panneau Change Panel Layout Modifier la disposition des panneaux Close Panel Fermer le panneau + Collapse Panel Group Réduire le groupe de panneaux Move Panel Déplacer le panneau Group with Regrouper avec Resize panels Redimensionner les panneaux @@ -4579,6 +4581,12 @@ effect Soft Doux @msg before must name a panel before doit désigner un panneau @msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long @msg group must be true or false group doit être true ou false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} le panneau est flottant ; réduisez son groupe avec window.panel.float {panel, collapsed} +@msg panel is not in a dock group le panneau n’est dans aucun groupe du dock +@msg only a dock group above or below others collapses, not one beside another seul un groupe du dock situé au-dessus ou au-dessous d’autres se réduit, pas un groupe à côté +@msg icon must be true or false icon doit être true ou false +@msg icon only docks a panel in the icon column, with no anchor icon ne fait qu’ancrer un panneau dans la colonne d’icônes, sans anchor +@msg column docks a set as a dock column, with no anchor or icon column ancre un ensemble comme colonne du dock, sans anchor ni icon @msg move requires a destination panel Le déplacement nécessite un panneau de destination @msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant @msg onto must name a floating panel onto doit désigner un panneau flottant @@ -5327,6 +5335,7 @@ theme Appearance Apparence @msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus). + Gap Length: Longueur de lacune : Artboard {n} of {total} Plan de travail {n} sur {total} Encoding and writing the file… Encodage et écriture du fichier… @plural {n} more export waiting|{n} more exports waiting {n} autre exportation en attente|{n} autres exportations en attente @@ -5368,5 +5377,11 @@ theme Appearance Apparence Water Paper Papier humide Ruby… Ruby… Ruby Ruby + Ease In Accélération progressive + Ease Out Décélération progressive + Ease In Out Accélération et décélération + Ease Out In Décélération et accélération + Ramp: Intensité : + Shift: Décalage : Fresco… Fresque… Fresco Fresque diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b80a373f1c..361df44510 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -641,6 +641,7 @@ Close All Összes bezárása Close Library Könyvtár bezárása Close Panel Panel bezárása + Collapse Panel Group Panelcsoport összecsukása Close Path Útvonal lezárása Close paths when ends are within: Útvonalak lezárása, ha a végek távolsága legfeljebb: Clumped Csomós @@ -981,6 +982,7 @@ Diffuse Glow Szórt ragyogás Diffuse Glow… Szórt ragyogás… Diffusion Diffúziós + Character Direction Karakterirány Digits Számjegyek Dim Copies to: Másolatok halványítása: Dim Images to: Képek halványítása: @@ -4595,6 +4597,12 @@ imageTrace Outline Körvonal @msg give the path `id` and the width point `index` add meg az útvonal `id` és a szélességi pont `index` értékét @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it a színátmenet nincs megadva a fájlban: „{name}”; helyette egyszínű kitöltés áll @msg group must be true or false a group értéke true vagy false lehet +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} a panel lebeg; a csoportját a window.panel.float {panel, collapsed} paranccsal csukhatja össze +@msg panel is not in a dock group a panel nincs dokkolt csoportban +@msg only a dock group above or below others collapses, not one beside another csak a többi fölötti vagy alatti dokkolt csoport csukható össze, a mellettük lévő nem +@msg icon must be true or false az icon értéke true vagy false lehet +@msg icon only docks a panel in the icon column, with no anchor az icon csak az ikonoszlopba dokkol egy panelt, anchor nélkül +@msg column docks a set as a dock column, with no anchor or icon a column egy készletet dokkoszlopként dokkol, anchor és icon nélkül @msg guides are locked a segédvonalak zárolva vannak @msg handle must be in or out handle: in vagy out kell @msg image '{key}' has no pixels and was left out a kép kimaradt, mert nincsenek pixelei: '{key}' @@ -5314,6 +5322,7 @@ imageTrace Outline Körvonal @msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel). + Gap Length: Rés hossza: Artboard {n} of {total} Rajztábla: {n} / {total} Encoding and writing the file… A fájl kódolása és írása… @plural {n} more export waiting|{n} more exports waiting Még {n} exportálás várakozik @@ -5355,5 +5364,11 @@ imageTrace Outline Körvonal Water Paper Nedves papír Ruby… Ruby… Ruby Ruby + Ease In Lassú kezdés + Ease Out Lassú befejezés + Ease In Out Lassú kezdés és befejezés + Ease Out In Lassú befejezés és kezdés + Ramp: Erősség: + Shift: Eltolás: Fresco… Freskó… Fresco Freskó diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f6a04ff986..f30d0ee1d5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -761,6 +761,7 @@ Activate Panel Aktifkan panel Change Panel Layout Ubah tata letak panel Close Panel Tutup panel + Collapse Panel Group Ciutkan grup panel Move Panel Pindahkan panel Group with Grupkan dengan Resize panels Ubah ukuran panel @@ -1311,6 +1312,7 @@ lighting Light Cahaya Leading Leading No Break Tanpa pemenggalan Proportional Metrics Metrik proporsional + Character Direction Arah karakter Digits Digit Arabic Arab Hindi Hindi @@ -4450,6 +4452,12 @@ imageTrace Outline Kontur @msg before must name a panel before harus menyebutkan panel @msg docking action contains an unknown panel or an excessive split path tindakan docking berisi panel tidak dikenal atau jalur pemisahan berlebihan @msg group must be true or false group harus true atau false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel mengambang; ciutkan grupnya dengan window.panel.float {panel, collapsed} +@msg panel is not in a dock group panel tidak berada dalam grup dok +@msg only a dock group above or below others collapses, not one beside another hanya grup dok di atas atau di bawah grup lain yang bisa diciutkan, bukan yang berada di sampingnya +@msg icon must be true or false icon harus true atau false +@msg icon only docks a panel in the icon column, with no anchor icon hanya menambatkan panel di kolom ikon, tanpa anchor +@msg column docks a set as a dock column, with no anchor or icon column menambatkan satu set sebagai kolom dok, tanpa anchor atau icon @msg move requires a destination panel move memerlukan panel tujuan @msg move requires an existing destination panel move memerlukan panel tujuan yang sudah ada @msg onto must name a floating panel onto harus menyebutkan panel mengambang @@ -5281,6 +5289,7 @@ swatch Bright Violet Violet cerah Persistent Persisten Locate Library… Temukan pustaka… Library Panel Options Opsi panel pustaka + Gap Length: Panjang celah: Artboard {n} of {total} Artboard {n} dari {total} Encoding and writing the file… Mengodekan dan menulis berkas… @plural {n} more export waiting|{n} more exports waiting {n} ekspor lagi menunggu @@ -5342,5 +5351,11 @@ swatch Bright Violet Violet cerah Water Paper Kertas Basah Ruby… Ruby… Ruby Ruby + Ease In Awal Lambat + Ease Out Akhir Lambat + Ease In Out Awal dan Akhir Lambat + Ease Out In Akhir dan Awal Lambat + Ramp: Kekuatan: + Shift: Pergeseran: Fresco… Fresko… Fresco Fresko diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf84398b78..483a49026c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2425,6 +2425,7 @@ mark-style Japanese Giapponese Proof Setup Impostazione prova Properties Proprietà Proportional Metrics Metrica proporzionale + Character Direction Direzione caratteri Digits Cifre Arabic Arabe Hindi Hindi @@ -4599,6 +4600,7 @@ effect Regular Normale Activate Panel Attiva pannello Change Panel Layout Modifica disposizione dei pannelli Close Panel Chiudi pannello + Collapse Panel Group Comprimi gruppo di pannelli Move Panel Sposta pannello Group with Raggruppa con Resize panels Ridimensiona pannelli @@ -4609,6 +4611,12 @@ effect Regular Normale @msg before must name a panel before deve indicare un pannello @msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo @msg group must be true or false group deve essere true o false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} il pannello è mobile; comprimi il suo gruppo con window.panel.float {panel, collapsed} +@msg panel is not in a dock group il pannello non è in un gruppo del dock +@msg only a dock group above or below others collapses, not one beside another si comprime solo un gruppo del dock sopra o sotto altri, non uno accanto +@msg icon must be true or false icon deve essere true o false +@msg icon only docks a panel in the icon column, with no anchor icon ancora un pannello solo nella colonna delle icone, senza anchor +@msg column docks a set as a dock column, with no anchor or icon column ancora un insieme come colonna del dock, senza anchor né icon @msg move requires a destination panel Lo spostamento richiede un pannello di destinazione @msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente @msg onto must name a floating panel onto deve indicare un pannello mobile @@ -4942,6 +4950,7 @@ lighting Light Luce @msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel). + Gap Length: Lunghezza lacuna: Artboard {n} of {total} Tavola da disegno {n} di {total} Encoding and writing the file… Codifica e scrittura del file… @plural {n} more export waiting|{n} more exports waiting {n} altra esportazione in attesa|{n} altre esportazioni in attesa @@ -4984,5 +4993,11 @@ lighting Light Luce Water Paper Carta bagnata Ruby… Ruby… Ruby Ruby + Ease In Inizio graduale + Ease Out Fine graduale + Ease In Out Inizio e fine graduali + Ease Out In Fine e inizio graduali + Ramp: Intensità: + Shift: Spostamento: Fresco… Affresco… Fresco Affresco diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5c23caf5f8..310eca9499 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -39,6 +39,7 @@ ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 Proportional Metrics プロポーショナルメトリクス + Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 Hindi ヒンディー数字 @@ -4638,6 +4639,7 @@ effect Vertical 垂直 Activate Panel パネルをアクティブにする Change Panel Layout パネルの配置を変更 Close Panel パネルを閉じる + Collapse Panel Group パネルグループを折りたたむ Move Panel パネルを移動 Group with グループ化先 Resize panels パネルのサイズを変更 @@ -4649,6 +4651,12 @@ effect Vertical 垂直 @msg before must name a panel before にパネルを指定してください @msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています @msg group must be true or false group は true または false にしてください +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} パネルはフローティング中です。グループは window.panel.float {panel, collapsed} で折りたたんでください +@msg panel is not in a dock group パネルはドックのグループにありません +@msg only a dock group above or below others collapses, not one beside another 折りたためるのは他のグループの上下にあるドックのグループだけで、横に並んだグループは折りたためません +@msg icon must be true or false icon は true または false である必要があります +@msg icon only docks a panel in the icon column, with no anchor icon はパネルをアイコン列にのみドッキングします(anchor なし) +@msg column docks a set as a dock column, with no anchor or icon column はセットをドック列としてドッキングします(anchor・icon なし) @msg move requires a destination panel 移動先のパネルを指定してください @msg move requires an existing destination panel 移動先には既存のパネルを指定してください @msg onto must name a floating panel onto にフローティングパネルを指定してください @@ -4982,6 +4990,7 @@ lighting Light ライト @msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。 + Gap Length: ギャップの長さ: Artboard {n} of {total} アートボード {n} / {total} Encoding and writing the file… ファイルをエンコードして書き込み中… @plural {n} more export waiting|{n} more exports waiting 待機中の書き出し: あと {n} 件 @@ -5031,5 +5040,11 @@ lighting Light ライト Yonroku-ban 四六判 Ruby… ルビ… Ruby ルビ + Ease In イーズイン + Ease Out イーズアウト + Ease In Out イーズインアウト + Ease Out In イーズアウトイン + Ramp: 強さ: + Shift: シフト: Fresco… フレスコ… Fresco フレスコ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 97b28b7c03..6e587478fe 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -703,6 +703,7 @@ Activate Panel პანელის გააქტიურება Change Panel Layout პანელების განლაგების შეცვლა Close Panel პანელის დახურვა + Collapse Panel Group პანელების ჯგუფის აკეცვა Move Panel პანელის გადაადგილება Group with ერთად დაჯგუფება Resize panels პანელების ზომის შეცვლა @@ -1226,6 +1227,7 @@ lighting Light სინათლე Leading სტრიქონთა დაშორება No Break გადატანის გარეშე Proportional Metrics პროპორციული მეტრიკა + Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული Hindi ჰინდი @@ -4201,6 +4203,12 @@ effect Regular ჩვეულებრივი @msg before must name a panel before უნდა ასახელებდეს პანელს @msg docking action contains an unknown panel or an excessive split path მიმაგრების მოქმედება შეიცავს უცნობ პანელს ან ზედმეტად გრძელ გაყოფის გზას @msg group must be true or false group უნდა იყოს true ან false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} პანელი მცურავია; მისი ჯგუფი აკეცეთ window.panel.float {panel, collapsed}-ით +@msg panel is not in a dock group პანელი არ არის დოკის ჯგუფში +@msg only a dock group above or below others collapses, not one beside another იკეცება მხოლოდ სხვების ზემოთ ან ქვემოთ მდებარე დოკის ჯგუფი, არა სხვის გვერდით მდებარე +@msg icon must be true or false icon უნდა იყოს true ან false +@msg icon only docks a panel in the icon column, with no anchor icon პანელს მხოლოდ ხატულების სვეტში ამაგრებს, anchor-ის გარეშე +@msg column docks a set as a dock column, with no anchor or icon column ნაკრებს დოკის სვეტად ამაგრებს, anchor-ის ან icon-ის გარეშე @msg move requires a destination panel move-ს სჭირდება სამიზნე პანელი @msg move requires an existing destination panel move-ს სჭირდება არსებული სამიზნე პანელი @msg onto must name a floating panel onto უნდა ასახელებდეს მცურავ პანელს @@ -5230,6 +5238,7 @@ imageTrace Outline კონტური # Select by Color (select.color) Select by Color მონიშვნა ფერის მიხედვით @msg thumbnail: no page {n} (the file has 1) thumbnail: გვერდი {n} არ არსებობს (ფაილს 1 გვერდი აქვს) + Gap Length: ღრეჩოს სიგრძე: Smudge Stick… დასაზელი ჯოხი… Smudge Stick დასაზელი ჯოხი Sponge… ღრუბელი… @@ -5279,5 +5288,11 @@ imageTrace Outline კონტური selects several, or draws one inside an artboard) რამდენიმეს მონიშნავს ან ახალს სამუშაო დაფის შიგნით ხატავს) Ruby… რუბი… Ruby რუბი + Ease In ნელი დასაწყისი + Ease Out ნელი დასასრული + Ease In Out ნელი დასაწყისი და დასასრული + Ease Out In ნელი დასასრული და დასაწყისი + Ramp: სიძლიერე: + Shift: წანაცვლება: Fresco… ფრესკა… Fresco ფრესკა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index af6b365d01..07a524ccb5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -35,6 +35,7 @@ ICF Top/Right ICF 위/오른쪽 ICF Bottom/Left ICF 아래/왼쪽 Proportional Metrics 비례 메트릭 + Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 Hindi 힌디어 @@ -4568,6 +4569,7 @@ effect Vertical 세로 Activate Panel 패널 활성화 Change Panel Layout 패널 레이아웃 변경 Close Panel 패널 닫기 + Collapse Panel Group 패널 그룹 접기 Move Panel 패널 이동 Group with 함께 그룹화 Resize panels 패널 크기 조절 @@ -4579,6 +4581,12 @@ effect Vertical 세로 @msg before must name a panel before는 패널을 지정해야 합니다 @msg docking action contains an unknown panel or an excessive split path 도킹 동작에 알 수 없는 패널이나 지나치게 많은 분할 경로가 있습니다 @msg group must be true or false group은 true 또는 false여야 합니다 +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 패널이 분리되어 있습니다. 그룹은 window.panel.float {panel, collapsed}로 접으세요 +@msg panel is not in a dock group 패널이 도크 그룹에 없습니다 +@msg only a dock group above or below others collapses, not one beside another 다른 그룹의 위나 아래에 있는 도크 그룹만 접을 수 있으며, 옆에 있는 그룹은 접을 수 없습니다 +@msg icon must be true or false icon은 true 또는 false여야 합니다 +@msg icon only docks a panel in the icon column, with no anchor icon은 anchor 없이 패널을 아이콘 열에만 도킹합니다 +@msg column docks a set as a dock column, with no anchor or icon column은 anchor나 icon 없이 세트를 도크 열로 도킹합니다 @msg move requires a destination panel move에는 대상 패널이 필요합니다 @msg move requires an existing destination panel move에는 존재하는 대상 패널이 필요합니다 @msg onto must name a floating panel onto는 떠 있는 패널을 지정해야 합니다 @@ -4905,6 +4913,7 @@ lighting Light 광원 @msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀). + Gap Length: 간격 길이: Artboard {n} of {total} 아트보드 {n} / {total} Encoding and writing the file… 파일을 인코딩하고 쓰는 중… @plural {n} more export waiting|{n} more exports waiting 대기 중인 내보내기 {n}개 더 @@ -4947,5 +4956,11 @@ lighting Light 광원 Water Paper 물 종이 Ruby… 루비… Ruby 루비 + Ease In 서서히 시작 + Ease Out 서서히 종료 + Ease In Out 서서히 시작 및 종료 + Ease Out In 서서히 종료 및 시작 + Ramp: 강도: + Shift: 이동: Fresco… 프레스코… Fresco 프레스코 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5655184bbf..8b409e7c31 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -775,6 +775,7 @@ Activate Panel Paneel activeren Change Panel Layout Paneelindeling wijzigen Close Panel Paneel sluiten + Collapse Panel Group Paneelgroep samenvouwen Move Panel Paneel verplaatsen Group with Groeperen met Resize panels Panelen van formaat wijzigen @@ -1325,6 +1326,7 @@ lighting Light Licht Leading Regelafstand No Break Niet afbreken Proportional Metrics Proportionele metingen + Character Direction Tekenrichting Digits Cijfers Arabic Arabisch Hindi Hindi @@ -4462,6 +4464,12 @@ imageTrace Outline Contour @msg before must name a panel before moet een paneel benoemen @msg docking action contains an unknown panel or an excessive split path dokactie bevat een onbekend paneel of een overmatig splitspad @msg group must be true or false group moet true of false zijn +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} paneel zweeft; vouw de groep samen met window.panel.float {panel, collapsed} +@msg panel is not in a dock group paneel staat niet in een dockgroep +@msg only a dock group above or below others collapses, not one beside another alleen een dockgroep boven of onder andere vouwt samen, niet een ernaast +@msg icon must be true or false icon moet true of false zijn +@msg icon only docks a panel in the icon column, with no anchor icon dockt een paneel alleen in de pictogramkolom, zonder anchor +@msg column docks a set as a dock column, with no anchor or icon column dockt een set als dockkolom, zonder anchor of icon @msg move requires a destination panel move vereist een doelpaneel @msg move requires an existing destination panel move vereist een bestaand doelpaneel @msg onto must name a floating panel onto moet een zwevend paneel benoemen @@ -5291,6 +5299,7 @@ swatch Bright Violet Felviolet Typing Typen Persistent Permanent Library Panel Options Opties voor bibliotheekpaneel + Gap Length: Lengte van opening: Locate Library… Bibliotheek lokaliseren… @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Opslaan schrijft {_1}, niet {_2}: gebruik file.saveAsTemplate om het te schrijven @@ -5384,5 +5393,11 @@ swatch Bright Violet Felviolet Water Paper Nat papier Ruby… Ruby… Ruby Ruby + Ease In Langzaam beginnen + Ease Out Langzaam eindigen + Ease In Out Langzaam beginnen en eindigen + Ease Out In Langzaam eindigen en beginnen + Ramp: Sterkte: + Shift: Verschuiving: Fresco… Fresco… Fresco Fresco diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7689deb1aa..b336d529ac 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -770,6 +770,7 @@ Activate Panel Aktywuj panel Change Panel Layout Zmień układ paneli Close Panel Zamknij panel + Collapse Panel Group Zwiń grupę paneli Move Panel Przesuń panel Group with Grupuj z Resize panels Zmień rozmiar paneli @@ -1307,6 +1308,7 @@ lighting Light Światło Leading Interlinia No Break Bez podziału Proportional Metrics Metryka proporcjonalna + Character Direction Kierunek znaków Digits Cyfry Arabic Arabskie Hindi Hindi @@ -4577,6 +4579,12 @@ imageTrace Outline Kontur @msg before must name a panel before musi wskazywać panel @msg docking action contains an unknown panel or an excessive split path operacja dokowania zawiera nieznany panel lub zbyt długą ścieżkę podziału @msg group must be true or false group musi mieć wartość true lub false +@msg icon must be true or false icon musi mieć wartość true lub false +@msg icon only docks a panel in the icon column, with no anchor icon tylko dokuje panel w kolumnie ikon, bez anchor +@msg column docks a set as a dock column, with no anchor or icon column dokuje zestaw jako kolumnę doku, bez anchor ani icon +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel jest pływający; zwiń jego grupę za pomocą window.panel.float {panel, collapsed} +@msg panel is not in a dock group panel nie należy do żadnej grupy doku +@msg only a dock group above or below others collapses, not one beside another zwinąć można tylko grupę doku nad lub pod innymi, a nie obok innej @msg move requires a destination panel przeniesienie wymaga panelu docelowego @msg move requires an existing destination panel przeniesienie wymaga istniejącego panelu docelowego @msg onto must name a floating panel onto musi wskazywać panel pływający @@ -5180,6 +5188,7 @@ mark-style Japanese Japońskie @msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli). + Gap Length: Długość przerwy: Artboard {n} of {total} Obszar kompozycji {n} z {total} Encoding and writing the file… Kodowanie i zapisywanie pliku… @plural {n} more export waiting|{n} more exports waiting Czeka jeszcze {n} eksport|Czekają jeszcze {n} eksporty|Czeka jeszcze {n} eksportów @@ -5221,5 +5230,11 @@ mark-style Japanese Japońskie Water Paper Mokry papier Ruby… Ruby… Ruby Ruby + Ease In Łagodny początek + Ease Out Łagodny koniec + Ease In Out Łagodny początek i koniec + Ease Out In Łagodny koniec i początek + Ramp: Siła: + Shift: Przesunięcie: Fresco… Fresk… Fresco Fresk diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e44b809693..82cfa0312a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1976,6 +1976,7 @@ Proof Setup Configuração de Prova Properties Propriedades Proportional Metrics Métrica Proporcional + Character Direction Direção de Caracteres Digits Dígitos Arabic Arábicos Hindi Hindi @@ -3056,6 +3057,7 @@ imageTrace Outline Contorno Activate Panel Ativar Painel Change Panel Layout Alterar Layout dos Painéis Close Panel Fechar Painel + Collapse Panel Group Recolher Grupo de Painéis Move Panel Mover Painel Group with Agrupar com Resize panels Redimensionar painéis @@ -4021,6 +4023,12 @@ axis Both Ambos @msg before must name a panel before deve nomear um painel @msg docking action contains an unknown panel or an excessive split path a ação de encaixe contém um painel desconhecido ou um caminho de divisão excessivo @msg group must be true or false group deve ser true ou false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} o painel está flutuando; recolha o grupo com window.panel.float {panel, collapsed} +@msg panel is not in a dock group o painel não está em um grupo do encaixe +@msg only a dock group above or below others collapses, not one beside another só um grupo do encaixe acima ou abaixo de outros é recolhido, não um ao lado +@msg icon must be true or false icon deve ser true ou false +@msg icon only docks a panel in the icon column, with no anchor icon apenas encaixa um painel na coluna de ícones, sem anchor +@msg column docks a set as a dock column, with no anchor or icon column encaixa um conjunto como coluna do encaixe, sem anchor nem icon @msg move requires a destination panel mover exige um painel de destino @msg move requires an existing destination panel mover exige um painel de destino existente @msg onto must name a floating panel onto deve nomear um painel flutuante @@ -5245,6 +5253,7 @@ mark-style Japanese Japonesas @msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels). + Gap Length: Comprimento da Lacuna: Artboard {n} of {total} Mesa de Trabalho {n} de {total} Encoding and writing the file… Codificando e gravando o arquivo… @plural {n} more export waiting|{n} more exports waiting Mais {n} exportação aguardando|Mais {n} exportações aguardando @@ -5286,5 +5295,11 @@ mark-style Japanese Japonesas Water Paper Papel molhado Ruby… Ruby… Ruby Ruby + Ease In Entrada suave + Ease Out Saída suave + Ease In Out Entrada e saída suaves + Ease Out In Saída e entrada suaves + Ramp: Intensidade: + Shift: Deslocamento: Fresco… Afresco… Fresco Afresco diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 03ebff5f38..b754faff99 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2421,6 +2421,7 @@ mark-style Japanese Японский Proof Setup Настройка цветопробы Properties Свойства Proportional Metrics Пропорциональные метрики + Character Direction Направление символов Digits Цифры Arabic Арабские Hindi Хинди @@ -4595,6 +4596,7 @@ axis Both Оба Activate Panel Активировать панель Change Panel Layout Изменить расположение панелей Close Panel Закрыть панель + Collapse Panel Group Свернуть группу панелей Move Panel Переместить панель Group with Сгруппировать с Resize panels Изменить размер панелей @@ -4605,6 +4607,12 @@ axis Both Оба @msg before must name a panel before должен указывать панель @msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений @msg group must be true or false group должен иметь значение true или false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панель плавающая; сверните её группу через window.panel.float {panel, collapsed} +@msg panel is not in a dock group панель не находится в группе дока +@msg only a dock group above or below others collapses, not one beside another свернуть можно только группу дока над или под другими, а не рядом с ними +@msg icon must be true or false icon должен быть true или false +@msg icon only docks a panel in the icon column, with no anchor icon только закрепляет панель в столбце значков, без anchor +@msg column docks a set as a dock column, with no anchor or icon column закрепляет набор как столбец дока, без anchor и icon @msg move requires a destination panel Для перемещения требуется целевая панель @msg move requires an existing destination panel Для перемещения требуется существующая целевая панель @msg onto must name a floating panel onto должен указывать плавающую панель @@ -4938,6 +4946,7 @@ lighting Light Свет @msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей). + Gap Length: Длина зазора: Artboard {n} of {total} Монтажная область {n} из {total} Encoding and writing the file… Кодирование и запись файла… @plural {n} more export waiting|{n} more exports waiting Ещё {n} экспорт в очереди|Ещё {n} экспорта в очереди|Ещё {n} экспортов в очереди @@ -4980,5 +4989,11 @@ lighting Light Свет Water Paper Мокрая бумага Ruby… Руби… Ruby Руби + Ease In Плавное начало + Ease Out Плавное окончание + Ease In Out Плавное начало и окончание + Ease Out In Плавное окончание и начало + Ramp: Сила: + Shift: Сдвиг: Fresco… Фреска… Fresco Фреска diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 925d97a4ab..a1e6519724 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2342,6 +2342,7 @@ Proof Setup Provtrycksinställningar Properties Egenskaper Proportional Metrics Proportionella mått + Character Direction Teckenriktning Digits Siffror Arabic Arabiska Hindi Hindi @@ -4480,6 +4481,7 @@ effect Soft Mjuk Activate Panel Aktivera panel Change Panel Layout Ändra panellayout Close Panel Stäng panel + Collapse Panel Group Komprimera panelgrupp Move Panel Flytta panel Group with Gruppera med Resize panels Ändra storlek på paneler @@ -4490,6 +4492,12 @@ effect Soft Mjuk @msg before must name a panel before måste ange en panel @msg docking action contains an unknown panel or an excessive split path dockningsåtgärden innehåller en okänd panel eller en alltför djup delningssökväg @msg group must be true or false group måste vara true eller false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panelen flyter; komprimera dess grupp med window.panel.float {panel, collapsed} +@msg panel is not in a dock group panelen ligger inte i en dockningsgrupp +@msg only a dock group above or below others collapses, not one beside another bara en dockningsgrupp ovanför eller under andra komprimeras, inte en bredvid en annan +@msg icon must be true or false icon måste vara true eller false +@msg icon only docks a panel in the icon column, with no anchor icon dockar bara en panel i ikonkolumnen, utan anchor +@msg column docks a set as a dock column, with no anchor or icon column dockar en uppsättning som en dockningskolumn, utan anchor eller icon @msg move requires a destination panel flyttning kräver en målpanel @msg move requires an existing destination panel flyttning kräver en befintlig målpanel @msg onto must name a floating panel onto måste ange en flytande panel @@ -5284,6 +5292,7 @@ theme Appearance Utseende @msg too many library panels were opened in this session för många bibliotekspaneler har öppnats i den här sessionen @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar). + Gap Length: Lucklängd: Artboard {n} of {total} Rityta {n} av {total} Encoding and writing the file… Kodar och skriver filen… @plural {n} more export waiting|{n} more exports waiting {n} export till väntar|{n} exporter till väntar @@ -5325,5 +5334,11 @@ theme Appearance Utseende Water Paper Vått papper Ruby… Ruby… Ruby Ruby + Ease In Långsam start + Ease Out Långsamt slut + Ease In Out Långsam start och slut + Ease Out In Långsamt slut och start + Ramp: Styrka: + Shift: Förskjutning: Fresco… Fresk… Fresco Fresk diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0f70eed06e..564aa1eb8b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2417,6 +2417,7 @@ mark-style Japanese Японський Proof Setup Налаштування кольоропроби Properties Властивості Proportional Metrics Пропорційні метрики + Character Direction Напрямок символів Digits Цифри Arabic Арабські Hindi Гінді @@ -4581,6 +4582,7 @@ axis Both Обидві Activate Panel Активувати панель Change Panel Layout Змінити розташування панелей Close Panel Закрити панель + Collapse Panel Group Згорнути групу панелей Move Panel Перемістити панель Group with Згрупувати з Resize panels Змінити розмір панелей @@ -4591,6 +4593,12 @@ axis Both Обидві @msg before must name a panel before має вказувати панель @msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів @msg group must be true or false group має мати значення true або false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панель від’єднана; згорніть її групу через window.panel.float {panel, collapsed} +@msg panel is not in a dock group панель не входить до групи дока +@msg only a dock group above or below others collapses, not one beside another згорнути можна лише групу дока над або під іншими, а не поруч з ними +@msg icon must be true or false icon має бути true або false +@msg icon only docks a panel in the icon column, with no anchor icon лише закріплює панель у стовпці значків, без anchor +@msg column docks a set as a dock column, with no anchor or icon column закріплює набір як стовпець дока, без anchor та icon @msg move requires a destination panel Для переміщення потрібна цільова панель @msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель @msg onto must name a floating panel onto має вказувати плаваючу панель @@ -4922,6 +4930,7 @@ lighting Light Світло @msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів). + Gap Length: Довжина проміжку: Artboard {n} of {total} Монтажна область {n} з {total} Encoding and writing the file… Кодування та запис файлу… @plural {n} more export waiting|{n} more exports waiting Ще {n} експорт в очікуванні|Ще {n} експорти в очікуванні|Ще {n} експортів в очікуванні @@ -4964,5 +4973,11 @@ lighting Light Світло Water Paper Мокрий папір Ruby… Рубі… Ruby Рубі + Ease In Плавний початок + Ease Out Плавне завершення + Ease In Out Плавний початок і завершення + Ease Out In Плавне завершення і початок + Ramp: Сила: + Shift: Зсув: Fresco… Фреска… Fresco Фреска diff --git a/crates/ui-egui/src/i18n/untranslated_messages.txt b/crates/ui-egui/src/i18n/untranslated_messages.txt index 44c746d1a8..7ee0845b7c 100644 --- a/crates/ui-egui/src/i18n/untranslated_messages.txt +++ b/crates/ui-egui/src/i18n/untranslated_messages.txt @@ -55,6 +55,7 @@ Text options: {e} `background` must be a colour `burasagari` must be "none", "standard" or "forced" `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" +`charDirection` must be "default", "leftToRight" or "rightToLeft" `color` needs a `rule` to make the theme from `colors` is empty `columnType` is vertical, uniform, repeating or sliding @@ -329,6 +330,7 @@ bad edit bad fill colour bad fill {v} bad fit: {e} +bad model {other} (keep, new or document) bad new colour {v} bad object bad object number @@ -429,6 +431,7 @@ each of `items` is {{id, corners?}} with an object id, not {it} each row needs `from` each stop needs a position each subpath needs an anchors array +easingType must be easeIn, easeOut, easeInOut or easeOutIn editingData must be true or false editingData must be true, false or "always" edits overlap @@ -806,6 +809,7 @@ nothing to mark nothing to print: every artboard picked is blank nothing to print: the document has no art that prints object is not a path +object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object object {_1} has lost its file's contents: relink it object {_1} has no size object {_1} is not a placed document @@ -932,6 +936,7 @@ scope must be selection or all (got {other}) scribble is too long segment does not exist select a Shaper group +select a blend to set easing or color shift select a graph select a group or layer with visible, unlocked members select a layer @@ -1252,6 +1257,7 @@ zoom must be positive {key} must be 0–{MAX_ADD_AROUND} pt {key} must be Unix seconds (an integer) or null {key} must be [x,y] +{key} must be a percentage {min}..100 {key} must be a positive number, not {v} {key} must be an array of non-negative integer object ids {key} must be one of {_1} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7f5ced6bfe..3d8889c2fa 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -554,6 +554,7 @@ Close All Đóng tất cả Close Library Đóng thư viện Close Panel Đóng bảng + Collapse Panel Group Thu gọn nhóm bảng Close Path Đóng đường dẫn Close paths when ends are within: Đóng đường dẫn khi hai đầu cách nhau trong: Clumped Dạng cụm @@ -875,6 +876,7 @@ Differences from {preset}: Khác biệt so với {preset}: Diffuse Glow Ánh sáng khuếch tán Diffuse Glow… Ánh sáng khuếch tán… + Character Direction Hướng ký tự Digits Chữ số Dim Copies to: Làm mờ bản sao đến: Dim Images to: Làm mờ ảnh đến: @@ -4241,6 +4243,12 @@ theme Appearance Giao diện @msg give the path `id` and the width point `index` hãy cung cấp `id` đường dẫn và `index` điểm chiều rộng @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it chuyển sắc “{name}” không được định nghĩa trong tệp: một màu đơn thay thế @msg group must be true or false group phải là true hoặc false +@msg icon must be true or false icon phải là true hoặc false +@msg icon only docks a panel in the icon column, with no anchor icon chỉ neo bảng vào cột biểu tượng, không có anchor +@msg column docks a set as a dock column, with no anchor or icon column neo một bộ thành cột neo, không có anchor hay icon +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} bảng đang nổi; hãy thu gọn nhóm của nó bằng window.panel.float {panel, collapsed} +@msg panel is not in a dock group bảng không thuộc nhóm nào của dock +@msg only a dock group above or below others collapses, not one beside another chỉ thu gọn được nhóm dock nằm trên hoặc dưới các nhóm khác, không phải nhóm nằm cạnh @msg guides are locked đường căn bị khóa @msg handle must be in or out handle phải là in hoặc out @msg image '{key}' has no pixels and was left out ảnh '{key}' không có pixel và bị bỏ qua @@ -4922,6 +4930,7 @@ theme Appearance Giao diện @msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel). + Gap Length: Độ dài khoảng hở: Artboard {n} of {total} Bảng vẽ {n} / {total} Encoding and writing the file… Đang mã hóa và ghi tệp… @plural {n} more export waiting|{n} more exports waiting Còn {n} lượt xuất đang chờ @@ -4964,5 +4973,11 @@ theme Appearance Giao diện Water Paper Giấy ướt Ruby… Ruby… Ruby Ruby + Ease In Chậm lúc đầu + Ease Out Chậm lúc cuối + Ease In Out Chậm lúc đầu và cuối + Ease Out In Chậm lúc cuối và đầu + Ramp: Cường độ: + Shift: Dịch chuyển: Fresco… Bích họa… Fresco Bích họa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 66d4212736..ae2572bd16 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2298,6 +2298,7 @@ font-script Japanese 汉字 Proof Setup 校样设置 Properties 属性 Proportional Metrics 比例度量 + Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 Hindi 印地数字 @@ -4274,6 +4275,12 @@ imageTrace Outline 轮廓 @msg before must name a panel before 必须指定面板 @msg docking action contains an unknown panel or an excessive split path 停靠操作包含未知面板或过长的拆分路径 @msg group must be true or false group 必须是 true 或 false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 面板处于浮动状态;请使用 window.panel.float {panel, collapsed} 折叠其面板组 +@msg panel is not in a dock group 面板不在停放组中 +@msg only a dock group above or below others collapses, not one beside another 只有位于其他组上方或下方的停放组可以折叠,并排的不行 +@msg icon must be true or false icon 必须为 true 或 false +@msg icon only docks a panel in the icon column, with no anchor icon 只会把面板停放到图标栏中,不能带 anchor +@msg column docks a set as a dock column, with no anchor or icon column 将一组面板停放为停放栏,不能带 anchor 或 icon @msg move requires a destination panel 移动需要目标面板 @msg move requires an existing destination panel 移动需要现有的目标面板 @msg onto must name a floating panel onto 必须指定浮动面板 @@ -4883,6 +4890,7 @@ effect Vertical 垂直 Activate Panel 激活面板 Change Panel Layout 更改面板布局 Close Panel 关闭面板 + Collapse Panel Group 折叠面板组 Move Panel 移动面板 Group with 组合到 Resize panels 调整面板大小 @@ -5161,6 +5169,7 @@ lighting Light 光源 @msg too many library panels were opened in this session 本次会话中打开的库面板过多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。 + Gap Length: 间隙长度: # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 编辑原稿只能打开图像和文档文件;“{name}” 不是其中之一 @msg only http, https and mailto URLs open in the browser, not “{url}” 浏览器中只会打开 http、https 和 mailto 的 URL,不会打开 “{url}” @@ -5208,5 +5217,11 @@ lighting Light 光源 Water Paper 水彩画纸 Ruby… 注音… Ruby 注音 + Ease In 缓入 + Ease Out 缓出 + Ease In Out 缓入缓出 + Ease Out In 缓出缓入 + Ramp: 强度: + Shift: 偏移: Fresco… 壁画… Fresco 壁画 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 605915dd9d..b02097f8db 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2258,6 +2258,12 @@ file-filter Templates 範本 @msg before must name a panel before 必須指定面板 @msg docking action contains an unknown panel or an excessive split path 停駐動作包含未知的面板或過長的分割路徑 @msg group must be true or false group 必須是 true 或 false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 面板處於浮動狀態;請使用 window.panel.float {panel, collapsed} 摺疊其面板群組 +@msg panel is not in a dock group 面板不在停駐群組中 +@msg only a dock group above or below others collapses, not one beside another 只有位於其他群組上方或下方的停駐群組可以摺疊,並排的不行 +@msg icon must be true or false icon 必須為 true 或 false +@msg icon only docks a panel in the icon column, with no anchor icon 只會將面板停駐到圖示欄中,不能帶 anchor +@msg column docks a set as a dock column, with no anchor or icon column 會將一組面板停駐為停駐欄,不能帶 anchor 或 icon @msg move requires a destination panel 移動需要目的地面板 @msg move requires an existing destination panel 移動需要現有的目的地面板 @msg onto must name a floating panel onto 必須指定浮動面板 @@ -3166,6 +3172,7 @@ font-script Japanese 漢字 Proof Setup 校樣設定 Properties 內容 Proportional Metrics 比例度量 + Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 Hindi 印地數字 @@ -4689,6 +4696,7 @@ effect Soft 柔和 Activate Panel 啟用面板 Change Panel Layout 變更面板配置 Close Panel 關閉面板 + Collapse Panel Group 摺疊面板群組 Move Panel 移動面板 Group with 群組至 Resize panels 調整面板大小 @@ -4956,6 +4964,7 @@ lighting Light 光源 Persistent 持久 Locate Library… 尋找資料庫… Library Panel Options 資料庫面板選項 + Gap Length: 間隙長度: Artboard {n} of {total} 工作區域 {n} / {total} Encoding and writing the file… 正在編碼並寫入檔案… @plural {n} more export waiting|{n} more exports waiting 另有 {n} 個匯出等待中 @@ -5003,5 +5012,11 @@ lighting Light 光源 Water Paper 水彩畫紙 Ruby… 注音… Ruby 注音 + Ease In 緩入 + Ease Out 緩出 + Ease In Out 緩入緩出 + Ease Out In 緩出緩入 + Ramp: 強度: + Shift: 偏移: Fresco… 壁畫… Fresco 壁畫 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 4c02b48fa7..972b214add 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -410,16 +410,17 @@ fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { /// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, /// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s -/// place. A path that still ends in the extension of the name the panel suggested (`suggested`) -/// gets the picked type's extension instead. Without a picked type, or with one the panel did not +/// place. A path that still ends in the extension of the name the panel suggested (`suggested`), +/// in any letter case, gets the picked type's extension instead, unless that extension is one of +/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not /// offer, [`with_save_extension`] with `f`. fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { return with_save_extension(path, f, mode); }; - let has = std::path::Path::new(path).extension(); - if has.is_some() && has == std::path::Path::new(suggested).extension() { + let has = fileio::extension(path); + if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) { return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); } with_save_extension(path, g, mode) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4a376e750..4f2cd7ecb9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -321,6 +321,10 @@ pub struct CanvasCache { pub renderer: vectorcraft_render::Renderer, pub texture: Option, pub key: Option, + /// The document the frame shown was rendered from, and the options it was rendered with, with + /// [`Self::key`]: when only the document changes, the canvas renders again only where it + /// changed ([`vectorcraft_render::changed`]). + pub shown: Option<(std::sync::Arc, vectorcraft_render::RenderOptions)>, pub last_ms: f64, /// The frame shown drew raster effects from rasters made before their objects moved, or at a /// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside @@ -565,6 +569,7 @@ impl VectorcraftApp { renderer: vectorcraft_render::Renderer::new(), texture: None, key: None, + shown: None, last_ms: 0.0, moved: false, from_worker: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 54264de129..76f0b3176d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -459,6 +459,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), + ( + "window.panel.collapse", + "Collapse Panel Group", + "", + "{panel, collapsed?: bool} show the dock group holding a panel as its tabs only (or its panel again), as a double-click on its tab does; the panel becomes the group's shown one. Without `collapsed` it toggles. Floating groups collapse through window.panel.float {panel, collapsed}. Returns {panel, collapsed}", + ), ( "window.panel.float", "Float Panel", @@ -469,7 +475,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", group?: bool, column?: true or n} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}", + "{panel: id or \"tools\", group?: bool, column?: true or n, anchor?: id, zone?: \"tab\" | \"top\" | \"bottom\" | \"left\" | \"right\" | \"center\", before?: id, icon?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `anchor`, in the dock group holding that panel instead: `zone` \"tab\" (the default) as a tab of it (before the `before` panel's tab), as dropping it on that group's tabs does, \"top\" / \"bottom\" as a new group above / below it, as dropping it on that group's upper / lower half does (\"left\", \"right\" and \"center\" split or join it likewise); with `icon: true` (no `anchor`), as icons in the icon column, the tabbed group's panels too, as dropping it on the icon column does: it lives there from then on and pops out of its icon; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}, or with `anchor` or `icon: true` (the shared layout) {panel, floating, layout} and no `column`", ), ( "window.collapseDock", @@ -478,8 +484,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{collapsed?: bool} collapse the dock's Properties | Layers | Libraries group to icons (true), expand it (false) or toggle (omitted), as the double arrow at the top of the dock does; returns the new state", ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), - ("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"), - ("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"), + ( + "window.workspace", + "Workspace", + "", + "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", + ), + ( + "window.workspace.reset", + "Reset Essentials", + "", + "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", + ), ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), ("window.workspace.manage", "Manage Workspaces…", "", "{}"), ("window.workspace.delete", "Delete Workspace", "", "{name}"), diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a610899972..b0bc66e8aa 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -7,10 +7,12 @@ use crate::VectorcraftApp; #[derive(Clone, Default)] struct LegacyDrag { + panel: String, layout: Option>, open_panel: Option, floating: Vec, icons: std::collections::BTreeSet, + collapsed: std::collections::BTreeSet, } #[derive(Clone)] @@ -18,6 +20,8 @@ struct RootGroupDrag { panel: String, layout: Layout, hidden: std::collections::BTreeMap>, + collapsed: std::collections::BTreeSet, + open_panel: Option, generation: u64, } @@ -29,6 +33,7 @@ fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { app.ui.floating_panels = previous.floating; app.ui.docking = previous.layout; app.ui.docking_icons = previous.icons; + app.ui.docking_collapsed = previous.collapsed; craft_ui::docking::cancel_drag::(ctx, egui::Id::new("vectorcraft-panel-docking")); true } else { @@ -231,7 +236,18 @@ fn placement(params: &Value) -> Result, String> { } pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option> { + if id == "window.panel.collapse" { + return Some(collapse(app, params)); + } + // Docking into the dock's tree or its icon column is the shared layout's, whatever set the + // panel floats in. A dock column is the native sets', so it doesn't combine with either. + let to_icons = params.get("icon").is_some_and(|icon| icon != &Value::Bool(false)); + let into_tree = id == "window.panel.dock" && (params.get("anchor").is_some() || to_icons); + if into_tree && params.get("column").is_some() { + return Some(Err("column docks a set as a dock column, with no anchor or icon".into())); + } if matches!(id, "window.panel.float" | "window.panel.dock") + && !into_tree && (params.get("above").is_some() || params.get("below").is_some() || params.get("column").is_some() @@ -338,6 +354,16 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result Some(panel.clone()), _ => None, }; + // A panel moved, floated or closed leaves its group: that group isn't collapsed any more, and + // the panel doesn't take the collapse along. + let moved = match &action { + Action::Move { panel, .. } + | Action::OpenAt { panel, .. } + | Action::Open { panel, .. } + | Action::Float { panel, .. } + | Action::Close { panel } => Some(panel.clone()), + _ => None, + }; layout.apply(action).map_err(|error| error.to_string())?; restore_float_fallback(&mut layout, fallback)?; if let Some((panel, location)) = returning { @@ -348,6 +374,9 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result) -> Result Vec { + let Some(layout) = &ui.docking else { + return crate::floating::shown_tab(ui).map(|tab| tab.info().0.to_string()).into_iter().collect(); + }; + let mut out = Vec::new(); + // Each node, and whether it may be shortened (the root, or in a top/bottom split). + let mut pending: Vec<(&Node, bool)> = layout.root.iter().map(|root| (root, true)).collect(); + while let Some((node, shortens)) = pending.pop() { + match node { + Node::Tabs { panels, active } if shortens => out.extend(panels.get(*active).cloned()), + Node::Split { axis, first, second, .. } => { + let vertical = *axis == craft_ui::layout::SplitAxis::Vertical; + pending.extend([(second.as_ref(), vertical), (first.as_ref(), vertical)]); + } + _ => {} + } + } + out +} + +/// Whether the group of the shared layout's tree holding `panel` as a tab can be shortened to its +/// tabs (the whole tree, or above or below another group); `None` when `panel` isn't a tab of one +/// (it floats, or is in an accordion). +fn tab_group_shortens(layout: &Layout, panel: &str) -> Option { + let mut pending: Vec<(&Node, bool)> = layout.root.iter().map(|root| (root, true)).collect(); + while let Some((node, shortens)) = pending.pop() { + match node { + Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return Some(shortens), + Node::Split { axis, first, second, .. } => { + let vertical = *axis == craft_ui::layout::SplitAxis::Vertical; + pending.extend([(second.as_ref(), vertical), (first.as_ref(), vertical)]); + } + _ => {} + } + } + None +} + +/// Forget the collapsed groups that show another panel now (another tab clicked, or the panel +/// moved or closed): those are expanded. +pub(crate) fn prune_collapsed(ui: &mut crate::state::UiState) { + if ui.docking_collapsed.is_empty() { + return; + } + let shown = dock_groups_shown(ui); + ui.docking_collapsed.retain(|id| shown.contains(id)); +} + +/// `window.panel.collapse`: show the dock group holding `panel` as its tabs only, or expanded. +fn collapse(app: &mut VectorcraftApp, params: &Value) -> Result { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + let collapsed = crate::menus::opt_bool(params, "collapsed")?; + if crate::floating::group_of(&app.ui, panel).is_some() { + return Err("panel floats; collapse its group with window.panel.float {panel, collapsed}".into()); + } + match app.ui.docking.as_mut() { + Some(layout) => { + match tab_group_shortens(layout, panel) { + None => return Err("panel is not in a dock group".into()), + Some(false) => return Err("only a dock group above or below others collapses, not one beside another".into()), + Some(true) => {} + } + layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?; + sync_legacy(app); + } + None => { + let tab = crate::state::DockTab::from_id(panel) + .filter(|_| crate::floating::docked_tabs(&app.ui).any(|t| t.info().0 == panel)) + .ok_or("panel is not in a dock group")?; + app.ui.dock_tab = tab; + } + } + prune_collapsed(&mut app.ui); + let now = collapsed.unwrap_or(!app.ui.docking_collapsed.contains(panel)); + if now { + app.ui.docking_collapsed.insert(panel.into()); + } else { + app.ui.docking_collapsed.remove(panel); + } + Ok(json!({"panel": panel, "collapsed": now})) +} + +/// `node` as drawn with its collapsed groups (showing a panel in `collapsed`) as tall as their +/// tabs in a top/bottom split. The paths of the splits so sized go in `fixed`, the panels of +/// collapsed groups in `members`. Also returns the node's height when it is only collapsed groups. +fn collapse_node( + node: &Node, + collapsed: &std::collections::BTreeSet, + (tab, gap): (f32, f32), + path: &mut Vec, + fixed: &mut Vec>, + members: &mut std::collections::BTreeSet, +) -> (Node, Option) { + use craft_ui::layout::{SplitAxis, SplitSize}; + match node { + Node::Tabs { panels, active } if panels.get(*active).is_some_and(|id| collapsed.contains(id)) => { + members.extend(panels.iter().cloned()); + (node.clone(), Some(tab)) + } + Node::Split { axis, size, first, second } => { + path.push(false); + let (a, ha) = collapse_node(first, collapsed, (tab, gap), path, fixed, members); + path.pop(); + path.push(true); + let (b, hb) = collapse_node(second, collapsed, (tab, gap), path, fixed, members); + path.pop(); + let (sized, height) = match (axis, ha, hb) { + (SplitAxis::Vertical, Some(ha), Some(hb)) => (Some(SplitSize::FixedFirst(ha)), Some(ha + hb + gap)), + (SplitAxis::Vertical, Some(ha), None) => (Some(SplitSize::FixedFirst(ha)), None), + (SplitAxis::Vertical, None, Some(hb)) => (Some(SplitSize::FixedSecond(hb)), None), + _ => (None, None), + }; + if sized.is_some() { + fixed.push(path.clone()); + } + (Node::Split { axis: *axis, size: sized.unwrap_or(*size), first: Box::new(a), second: Box::new(b) }, height) + } + _ => (node.clone(), None), + } +} + fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; if operation == "move" && params.get("before").is_some() { @@ -386,11 +539,24 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result *value, Some(_) => return Err("group must be true or false".into()), }; + let to_icons = match params.get("icon") { + None => false, + Some(Value::Bool(value)) => *value, + Some(_) => return Err("icon must be true or false".into()), + }; + if to_icons && (operation != "dock" || params.get("anchor").or_else(|| params.get("onto")).is_some()) { + return Err("icon only docks a panel in the icon column, with no anchor".into()); + } let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] }; let mut hidden: std::collections::BTreeMap<_, _> = app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); let mut icons = icon_origins(app); - let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel); + if to_icons { + // Any panel the icon column can show lives there once dropped on it, the tabbed group's + // too; the others (library panels) dock as they would without `icon`. + icons.extend(members.iter().filter(|id| crate::state::rail_panel(id).is_some()).cloned()); + } + let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel) || icons.contains(panel); if icon_panel && !layout.contains(&panel.to_string()) { icons.insert(panel.to_string()); } @@ -561,6 +727,12 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result Result { app: &'a mut VectorcraftApp, commands: Vec<(&'static str, Value)>, + /// The panels of groups collapsed to their tabs: no body. + shut: std::collections::BTreeSet, } impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, panel: &String) { + if self.shut.contains(panel) { + return; + } let t = crate::theme::Tokens::get(ui.ctx()); let back = ui.max_rect(); if back.is_positive() { @@ -631,6 +808,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { panel: active.clone(), layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)), hidden: self.app.ui.docking_hidden.clone(), + collapsed: self.app.ui.docking_collapsed.clone(), + open_panel: self.app.ui.open_panel.clone(), generation: self.app.ui.docking_generation, }, ) @@ -642,6 +821,74 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } +/// The command that puts `panel` (with its group when `whole_group`) where a pointer drop on +/// `target` puts it. +fn drop_command(panel: &str, target: crate::floating::Drop, whole_group: bool) -> (&'static str, Value) { + use crate::floating::Drop; + match target { + Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})), + Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})), + Drop::Below(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "below": onto})), + Drop::Above(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "above": onto})), + Drop::Column(slot) => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "column": slot})), + Drop::Root(anchor, zone) => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "anchor": anchor, "zone": zone.id()})), + Drop::Icons => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "icon": true})), + } +} + +/// The groups of the shared dock's tree as drawn in `content` (the rect its tree was laid out in), +/// from where its tabs were drawn: a group's tabs start at its top-left corner, and the groups of +/// a split meet where the second one's tabs start. +fn root_groups(ctx: &egui::Context, layout: &Layout, content: egui::Rect, tab_height: f32) -> Vec { + let area = egui::Id::new("vectorcraft-panel-docking"); + let tab = |panel: &String| ctx.read_response(area.with(("tab", panel))).map(|response| response.rect).filter(|r| r.is_positive()); + /// The top-left corner of a node's first group, from where its tabs were drawn. + fn corner(node: &Node, tab: &dyn Fn(&String) -> Option) -> Option { + match node { + Node::Tabs { panels, .. } => panels.iter().filter_map(tab).map(|r| r.min).reduce(|a, b| a.min(b)), + Node::Stack { entries } => entries.first().and_then(|entry| tab(&entry.panel)).map(|r| r.min), + Node::Split { first, .. } => corner(first, tab), + } + } + let mut out = Vec::new(); + let mut pending: Vec<(&Node, egui::Rect)> = layout.root.iter().map(|root| (root, content)).collect(); + while let Some((node, rect)) = pending.pop() { + if out.len() >= craft_ui::docking::MAX_PANELS || !rect.is_positive() { + break; + } + match node { + Node::Tabs { panels, active } => { + let ids: Vec<&'static str> = panels.iter().filter_map(|id| normalize(id)).collect(); + let Some(shown) = panels.get(*active).and_then(|id| normalize(id)).or_else(|| ids.first().copied()) else { continue }; + let strip = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), (rect.top() + tab_height).min(rect.bottom()))); + out.push(crate::floating::RootGroup { panels: ids, shown, rect, strip }); + } + Node::Stack { entries } => { + let ids: Vec<&'static str> = entries.iter().filter_map(|entry| normalize(&entry.panel)).collect(); + let Some(shown) = ids.first().copied() else { continue }; + let strip = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), (rect.top() + tab_height).min(rect.bottom()))); + out.push(crate::floating::RootGroup { panels: ids, shown, rect, strip }); + } + Node::Split { axis, first, second, .. } => { + let Some(at) = corner(second, &tab) else { continue }; + let (a, b) = match axis { + craft_ui::layout::SplitAxis::Vertical => { + let y = at.y.clamp(rect.top(), rect.bottom()); + (egui::Rect::from_x_y_ranges(rect.x_range(), rect.top()..=y), egui::Rect::from_x_y_ranges(rect.x_range(), y..=rect.bottom())) + } + craft_ui::layout::SplitAxis::Horizontal => { + let x = at.x.clamp(rect.left(), rect.right()); + (egui::Rect::from_x_y_ranges(rect.left()..=x, rect.y_range()), egui::Rect::from_x_y_ranges(x..=rect.right(), rect.y_range())) + } + }; + pending.push((second, b)); + pending.push((first, a)); + } + } + } + out +} + pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let root_key = egui::Id::new("vectorcraft-root-group-drag"); if let Some(drag) = ui.ctx().data(|data| data.get_temp::(root_key)) { @@ -650,6 +897,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) { app.ui.docking = Some(drag.layout); app.ui.docking_hidden = drag.hidden; + app.ui.docking_collapsed = drag.collapsed; + app.ui.open_panel = drag.open_panel; sync_legacy(app); ui.ctx().data_mut(|data| data.remove::(root_key)); } else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) { @@ -666,13 +915,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { if let Some(panel) = normalize(&drag.panel) && let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) { - let (id, params) = match target { - crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})), - crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})), - crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})), - crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})), - crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})), - }; + let (id, params) = drop_command(panel, target, true); if let Err(error) = app.run(id, params) { app.ui.status = error; } @@ -733,6 +976,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { .collect(); crate::dock::apply_pending_dock_width(app, ui.ctx(), crate::dock::SHARED_DOCK_PANEL, crate::dock::clamp_shared_dock_width); let shared_dock_default = crate::dock::shared_dock_default_width(&app.ui); + // Splits sized to a collapsed group's tabs: their dividers don't resize the saved layout. + let mut fixed: Vec> = Vec::new(); let mut draw = |ui: &mut egui::Ui| { if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) { let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed})); @@ -775,22 +1020,62 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { style.panels_label = tl!("Panels").to_string(); style.resize_label = tl!("Resize panels").to_string(); style.resize_window_label = tl!("Resize panel window").to_string(); - let mut content = PanelContent { app, commands: Vec::new() }; - let output = craft_ui::docking::DockArea::new(area).show_customized( - ui, - &display, - &style, - |id| { - crate::state::all_panels() - .find(|p| p.0 == id) - .map(|p| tl!(p.1).to_string()) - .or_else(|| library_label(id)) - .unwrap_or_else(|| id.clone()) - }, - |_| craft_ui::docking::Permissions::default(), - |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, - &mut content, - ); + // Panels set their own minimum height, so a collapsed group can be as short as its tabs. + style.min_pane = 0.0; + let mut tree = ui.available_rect_before_wrap(); + let mut shut = std::collections::BTreeSet::new(); + let mut drawn = display.clone(); + // The height of the tree when it is only collapsed groups: drawn that tall, so no group + // takes the rest of the dock as a blank pane. + let mut only_tabs = None; + if let Some(root) = &display.root { + let (node, height) = + collapse_node(root, &app.ui.docking_collapsed, (style.tab_height, style.gap), &mut Vec::new(), &mut fixed, &mut shut); + drawn.root = Some(node); + only_tabs = height.filter(|h| h.is_finite() && *h > 0.0); + } + let short = shut.clone(); + let mut content = PanelContent { app, commands: Vec::new(), shut }; + let mut dock = |ui: &mut egui::Ui| { + craft_ui::docking::DockArea::new(area).show_customized( + ui, + &drawn, + &style, + |id| { + crate::state::all_panels() + .find(|p| p.0 == id) + .map(|p| tl!(p.1).to_string()) + .or_else(|| library_label(id)) + .unwrap_or_else(|| id.clone()) + }, + |_| craft_ui::docking::Permissions::default(), + |id| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, if short.contains(id) { 0.0 } else { 80.0 }), ..Default::default() }, + &mut content, + ) + }; + let output = match only_tabs { + Some(height) => { + let size = egui::vec2(ui.available_width(), height.min(ui.available_height())); + ui.allocate_ui(size, |ui| { + ui.set_max_height(size.y); + tree = ui.available_rect_before_wrap(); + dock(ui) + }) + .inner + } + None => dock(ui), + }; + // A double-click on a tab of the dock's tree collapses its group to its tabs, or expands it. + if let Some(root) = &drawn.root { + let tabs = Layout { root: Some(root.clone()), floating: Vec::new() }; + if let Some(panel) = + tabs.panels().into_iter().find(|p| ui.ctx().read_response(area.with(("tab", *p))).is_some_and(|r| r.double_clicked())) + { + content.commands.push(("window.panel.collapse", json!({"panel": panel}))); + } + } + let groups = if collapsed { Vec::new() } else { root_groups(ui.ctx(), &display, tree, style.tab_height) }; + ui.ctx().data_mut(|data| data.insert_temp(crate::floating::root_groups_id(), groups)); for (id, params) in content.commands { if let Err(error) = app.run(id, params) { app.ui.status = error; @@ -818,6 +1103,16 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { { crate::floating::preview(ui.ctx(), target, rect); } + // A tab dragged over the icon column becomes an icon there: light the column up (the shared + // dock shows the other drops). + if ui.input(|input| input.pointer.primary_down()) + && let Some(pos) = ui.input(|input| input.pointer.interact_pos()) + && let Some(panel) = dragged_tab(ui.ctx(), &layout).and_then(|panel| normalize(&panel)) + && let Some((target @ crate::floating::Drop::Icons, rect)) = + crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + { + crate::floating::preview(ui.ctx(), target, rect); + } if let Some(error) = output.error { app.ui.status = error.to_string(); } @@ -831,7 +1126,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { _ => return None, }; crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) - .filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock)) + .filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock | crate::floating::Drop::Root(..))) .map(|(target, _)| (panel, target)) }) }); @@ -850,6 +1145,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { ui.ctx().data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); } for action in output.actions { + if matches!(&action, Action::ResizeSplit { path, .. } if fixed.contains(path)) { + continue; + } if tab_drop .is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel)) { @@ -864,13 +1162,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true)) }); if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) { - let (command, params) = match target { - crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})), - crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})), - crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})), - crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})), - crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})), - }; + let (command, params) = drop_command(panel, target, whole_group); // Shared and native windows have already chosen their render ownership for this // frame. Switch representations only after both renderers finish, so a panel's // widgets never appear in two layers in the same frame. @@ -894,6 +1186,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { true } +/// The panel whose tab (of the shared dock, or of the legacy dock or icon column on the drag that +/// began the shared layout) is being dragged. +fn dragged_tab(ctx: &egui::Context, layout: &Layout) -> Option { + let area = egui::Id::new("vectorcraft-panel-docking"); + if let Some(drag) = ctx.data(|data| data.get_temp::(area.with("legacy-origin"))) { + return Some(drag.panel); + } + let dragged = ctx.dragged_id()?; + layout.panels().into_iter().find(|panel| area.with(("tab", *panel)) == dragged).cloned() +} + fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let column = egui::Panel::right("shared-panel-icons") @@ -904,21 +1207,41 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { crate::theme::card(ui, &t, egui::Margin { left: 3, right: 3, top: 6, bottom: 6 }, |ui| { egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { ui.spacing_mut().item_spacing.y = 2.0; - for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS + // The tabbed group's panels dropped on the column head it, then the icon panels, + // then the other panels dropped on it (those the column has no place for). + let tabs: Vec<_> = crate::state::DockTab::ALL + .into_iter() + .map(crate::state::DockTab::info) + .filter(|info| app.ui.docking_icons.contains(info.0)) + .collect(); + let grouped = |id: &str| crate::state::ICON_PANEL_GROUPS.iter().any(|group| group.contains(&id)); + let dropped: Vec<_> = crate::state::ICON_PANELS .iter() - .flat_map(|group| group.iter()) - .filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id)) + .filter(|entry| !grouped(entry.0) && app.ui.docking_icons.contains(entry.0)) + .copied() + .collect(); + for (id, label, icon) in tabs + .into_iter() + .chain( + crate::state::ICON_PANEL_GROUPS + .iter() + .flat_map(|group| group.iter()) + .filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id).copied()), + ) + .chain(dropped) { if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) { continue; } let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0)); let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag()); - let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id)); + let open = app.ui.open_panel.as_deref() == Some(id); + let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), open); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label))); if response.clicked() { app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) }; } - legacy_tab(app, ui, id, &response); + legacy_tab(app, ui, id, &response, false); } }); }) @@ -937,11 +1260,21 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { }); } -pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { - legacy_tab_in_strip(app, ui, panel, response, None); +/// A tab of the dock's tabbed group (`closable`) or an icon of the icon column: its context menu +/// floats, groups or (a tab) closes its panel, and a drag moves it in the shared layout. +pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response, closable: bool) { + legacy_tab_in_strip(app, ui, panel, response, closable, None); } -pub(crate) fn legacy_tab_in_strip(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response, strip: Option) { +/// [`legacy_tab`] for a tab of the tab strip `strip`, where a drag reorders the tabs. +pub(crate) fn legacy_tab_in_strip( + app: &mut VectorcraftApp, + ui: &mut egui::Ui, + panel: &str, + response: &egui::Response, + closable: bool, + strip: Option, +) { let Some(panel) = normalize(panel) else { return }; response.context_menu(|ui| { if ui.button(tl!("Float Panel")).clicked() { @@ -964,6 +1297,12 @@ pub(crate) fn legacy_tab_in_strip(app: &mut VectorcraftApp, ui: &mut egui::Ui, p } } }); + if closable && ui.button(tl!("Close Panel")).clicked() { + if let Err(error) = app.run("window.panel.close", json!({"panel": panel})) { + app.ui.status = error; + } + ui.close(); + } }); let inside_strip = strip.is_some_and(|strip| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| strip.expand(4.0).contains(p))); let area = egui::Id::new("vectorcraft-panel-docking"); @@ -983,10 +1322,12 @@ pub(crate) fn legacy_tab_in_strip(app: &mut VectorcraftApp, ui: &mut egui::Ui, p data.insert_temp( egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"), LegacyDrag { + panel: id.to_string(), layout: app.ui.docking.clone(), open_panel: app.ui.open_panel.clone(), floating: app.ui.floating_panels.clone(), icons: app.ui.docking_icons.clone(), + collapsed: app.ui.docking_collapsed.clone(), }, ) }); diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index dbd77ed33c..6c2ed6e29d 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -693,3 +693,440 @@ fn library_panels_float_with_their_names_and_a_persistent_menu_item() { let persistent: Vec<&str> = h.state().ui.library_panels.iter().filter(|p| p.persistent).map(|p| p.id.as_str()).collect(); assert_eq!(persistent, ["pastels"]); } + +/// The dock groups the last frame registered as drop targets. +fn root_groups(h: &Harness<'static, VectorcraftApp>) -> Vec { + h.ctx.data(|d| d.get_temp::>(crate::floating::root_groups_id())).unwrap_or_default() +} + +/// The dock's group holding `panel`, as panel ids. +fn root_group_of(app: &VectorcraftApp, panel: &str) -> Vec { + let layout = app.ui.docking.as_ref().unwrap(); + assert!(Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&panel.to_string()), "{panel} is in the dock: {layout:?}"); + group_members(layout, panel) +} + +/// A legacy (native) floating group holding `panel`, as the Window menu or an icon tear leaves it +/// before the shared layout takes over, and a point on its title bar. +fn native_float(h: &mut Harness<'static, VectorcraftApp>, panel: &'static str) -> Pos2 { + crate::floating::float(&mut h.state_mut().ui, &[panel], panel, egui::pos2(300.0, 300.0)); + h.run_steps(3); + assert!(h.state().ui.docking.is_none(), "still the legacy dock"); + let r = h.ctx.memory(|m| m.area_rect(crate::floating::area_id(panel))).unwrap(); + egui::pos2(r.left() + 30.0, r.top() + 1.0 + crate::floating::TITLE / 2.0) +} + +/// A floating group dropped on the dock's tab strip joins the tabbed group as tabs. +#[test] +fn a_floating_group_dropped_on_the_dock_tabs_joins_the_tabbed_group() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let title = native_float(&mut h, "pathfinder"); + let groups = root_groups(&h); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].panels, ["properties", "layers", "libraries"]); + let onto = groups[0].strip.right_center() - vec2(60.0, 0.0); + drag(&mut h, title, onto); + assert!(h.state().ui.floating_panels.is_empty(), "{:?}", h.state().ui.floating_panels); + assert_eq!(root_group_of(h.state(), "pathfinder"), ["properties", "layers", "libraries", "pathfinder"]); + assert!(!h.state().ui.docking_icons.contains("pathfinder"), "its home is the tabbed group now"); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +/// A floating group dropped on a dock group's lower half becomes a new group below it. +#[test] +fn a_floating_group_dropped_on_the_lower_half_of_the_dock_splits_it() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let title = native_float(&mut h, "pathfinder"); + let group = root_groups(&h)[0].rect; + drag(&mut h, title, egui::pos2(group.center().x, group.bottom() - 80.0)); + assert!(h.state().ui.floating_panels.is_empty()); + let layout = h.state().ui.docking.clone().unwrap(); + let Some(Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, second, .. }) = &layout.root else { + panic!("a top/bottom split: {layout:?}") + }; + assert_eq!(**second, Node::Tabs { panels: vec!["pathfinder".into()], active: 0 }); + assert_eq!(root_group_of(h.state(), "layers"), ["properties", "layers", "libraries"]); + // The two groups are drawn one above the other, and another group dropped on the upper half of + // the lower one goes between them. + h.run_steps(3); + let groups = root_groups(&h); + assert_eq!(groups.len(), 2, "{groups:?}"); + assert!(groups[0].rect.bottom() <= groups[1].rect.top() + 1.0, "{groups:?}"); + assert_eq!(groups[1].panels, ["pathfinder"]); + let lower = groups[1].rect; + h.state_mut().run("window.panel.float", json!({"panel":"align","x":300,"y":300})).unwrap(); + h.run_steps(3); + let title = + h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"align".to_string())))).unwrap().left_top() + + vec2(6.0, 16.0); + drag(&mut h, title, egui::pos2(lower.center().x, lower.top() + 50.0)); + assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); + h.run_steps(3); + let panels: Vec<_> = root_groups(&h).into_iter().map(|g| g.panels).collect(); + assert_eq!(panels, [vec!["properties", "layers", "libraries"], vec!["align"], vec!["pathfinder"]]); +} + +/// A floating group dropped on the icon column becomes icons there. +#[test] +fn a_floating_group_dropped_on_the_icon_column_becomes_icons() { + let mut h = harness(app(), vec2(1280.0, 1400.0)); + h.state_mut().run("window.panel.float", json!({"panel":"layers","x":300,"y":300})).unwrap(); + h.run_steps(3); + let title = + h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"layers".to_string())))).unwrap().left_top() + + vec2(6.0, 16.0); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + drag(&mut h, title, column.center()); + let ui = &h.state().ui; + assert!(ui.docking_icons.contains("layers")); + assert!(!ui.docking.as_ref().unwrap().contains(&"layers".to_string())); + assert_eq!(ui.open_panel.as_deref(), Some("layers"), "it pops out of its new icon"); +} + +/// A dock tab dragged onto the icon column becomes an icon whose flyout shows the panel. +#[test] +fn a_dock_tab_dragged_onto_the_icon_column_becomes_an_icon_and_its_flyout_shows() { + let mut h = harness(app(), vec2(1280.0, 1400.0)); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + drag(&mut h, tab, column.center()); + let ui = &h.state().ui; + assert!(ui.docking_icons.contains("layers"), "{:?}", ui.docking_icons); + assert_eq!(root_group_of(h.state(), "properties"), ["properties", "libraries"]); + // Its icon heads the column; a click puts the popped-out panel away and another shows it again. + h.state_mut().ui.open_panel = None; + h.run_steps(3); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + let icon = h.query_all_by_label("Layers").map(|node| node.rect()).find(|r| column.contains_rect(*r)).expect("a Layers icon in the column"); + h.event(Event::PointerMoved(icon.center())); + h.event(Event::PointerButton { pos: icon.center(), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: icon.center(), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(h.state().ui.open_panel.as_deref(), Some("layers")); + // The preferences keep it in the column. + let saved = serde_json::to_value(&h.state().ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert!(restored.sanitized().docking_icons.contains("layers")); + // Dragged from its icon back onto the tabbed group's tabs, it is a tab again. + let strip = root_groups(&h)[0].strip; + drag(&mut h, icon.center(), strip.right_center() - vec2(30.0, 0.0)); + assert_eq!(root_group_of(h.state(), "layers"), ["properties", "libraries", "layers"]); +} + +/// A dock tab's context menu closes its panel, and the Window menu brings it back. +#[test] +fn a_dock_tab_context_menu_closes_its_panel_and_the_window_menu_brings_it_back() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + assert!(h.state().ui.docking.is_none()); + let tab = h.query_all_by_label("Libraries").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(tab)); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + h.get_by_label("Close Panel").click(); + h.run_steps(3); + let layout = h.state().ui.docking.clone().expect("closing starts the shared layout"); + assert!(!layout.contains(&"libraries".to_string())); + assert_eq!(root_group_of(h.state(), "layers"), ["properties", "layers"]); + // In the shared dock the tabs' context menu closes them too. + let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + h.event(Event::PointerMoved(tab)); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + h.get_by_label("Close Panel").click(); + h.run_steps(3); + assert_eq!(root_group_of(h.state(), "properties"), ["properties"]); + h.state_mut().run("window.panel", json!({"panel":"libraries"})).unwrap(); + h.state_mut().run("window.panel", json!({"panel":"layers"})).unwrap(); + let mut panels = root_group_of(h.state(), "properties"); + panels.sort(); + assert_eq!(panels, ["layers", "libraries", "properties"]); +} + +/// `window.panel.dock {icon}` and `{anchor, zone}` check their parameters and survive a workspace switch. +#[test] +fn icon_docking_commands_validate_and_round_trip_through_workspaces() { + let mut app = app(); + for params in [json!({"panel":"layers","icon":3}), json!({"panel":"layers","icon":true,"anchor":"properties"})] { + assert!(app.run("window.panel.dock", params).is_err()); + assert!(app.ui.docking.is_none()); + } + assert!(app.run("window.panel.move", json!({"panel":"layers","anchor":"properties","icon":true})).is_err()); + let r = app.run("window.panel.dock", json!({"panel":"layers","icon":true})).unwrap(); + assert!(r.get("layout").is_some() && r.get("column").is_none(), "the shared layout's result: {r}"); + assert!(app.ui.docking_icons.contains("layers")); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"layers".to_string())); + let r = app.run("window.panel.dock", json!({"panel":"pathfinder","anchor":"properties","zone":"bottom"})).unwrap(); + assert!(r.get("layout").is_some() && r.get("column").is_none(), "the shared layout's result: {r}"); + assert!(matches!(&app.ui.docking.as_ref().unwrap().root, Some(Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, .. }))); + app.run("window.workspace.new", json!({"name":"Icons"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + assert!(!app.ui.docking_icons.contains("layers")); + app.run("window.workspace", json!({"name":"Icons"})).unwrap(); + assert!(app.ui.docking_icons.contains("layers")); + assert_eq!(root_group_of(&app, "pathfinder"), ["pathfinder"]); + // From the Window menu it pops out of its icon. + app.run("window.panel", json!({"panel":"layers"})).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some("layers")); +} + +/// `column` goes with neither `anchor` nor `icon: true`, so it is never silently dropped; +/// `icon: false` is the same as no `icon`, so a dock column still takes the panel. +#[test] +fn a_dock_column_is_refused_with_an_anchor_or_icon_and_icon_false_is_no_icon() { + let mut app = app(); + for params in [json!({"panel":"color","column":1,"anchor":"properties"}), json!({"panel":"color","column":true,"icon":true})] { + assert!(app.run("window.panel.dock", params.clone()).is_err(), "{params}"); + assert!(app.ui.docking.is_none() && app.ui.floating_panels.is_empty(), "{params} changed nothing"); + } + let r = app.run("window.panel.dock", json!({"panel":"color","column":true,"icon":false})).unwrap(); + assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false))); +} + +/// A group dropped on the icon column leaves what the column can't show (a library panel) in the +/// dock instead of losing it. +#[test] +fn a_library_panel_dropped_on_the_icon_column_with_its_group_stays_in_the_dock() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let library = "library:swatches:pastels".to_string(); + app.run("window.panel.float", json!({"panel": library, "x": 300, "y": 100})).unwrap(); + app.run("window.panel.move", json!({"panel":"color", "anchor": library})).unwrap(); + assert_eq!(group_members(app.ui.docking.as_ref().unwrap(), &library).len(), 2, "one floating group"); + app.run("window.panel.dock", json!({"panel": library, "group": true, "icon": true})).unwrap(); + assert!(app.ui.docking_icons.contains("color")); + assert!(!app.ui.docking_icons.contains(&library)); + assert!(Layout { root: app.ui.docking.as_ref().unwrap().root.clone(), floating: Vec::new() }.contains(&library), "in the dock"); +} + +/// A panel outside the icon groups still gets an icon once dropped on the column. +#[test] +fn a_panel_the_icon_column_has_no_place_for_still_shows_there_once_dropped_on_it() { + let mut h = harness(app(), vec2(1280.0, 1600.0)); + assert!(!crate::state::ICON_PANEL_GROUPS.iter().any(|group| group.contains(&"charStyles"))); + h.state_mut().run("window.panel.float", json!({"panel":"charStyles","x":300,"y":300})).unwrap(); + h.run_steps(3); + let title = + h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"charStyles".to_string())))).unwrap().left_top() + + vec2(6.0, 16.0); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + drag(&mut h, title, column.center()); + assert!(h.state().ui.docking_icons.contains("charStyles")); + h.state_mut().ui.open_panel = None; + h.run_steps(3); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + assert!(h.query_all_by_label("Character Styles").any(|node| column.contains_rect(node.rect())), "its icon is in the column"); +} + +/// Double-clicks at `at`, then waits out the double-click time. +fn double_click(h: &mut Harness<'static, VectorcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.run_steps(1); + for _ in 0..2 { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + // Past the double- and triple-click times, so the next press is a click of its own. + h.run_steps(45); +} + +/// The Layers panel's body is drawn (its search field). +fn layers_drawn(h: &Harness<'static, VectorcraftApp>) -> bool { + h.ctx.read_response(egui::Id::new("layers-search").with("edit")).is_some() +} + +/// Clicks at `at`. +fn click(h: &mut Harness<'static, VectorcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// The middle of `panel`'s tab in the shared dock. +/// Two stacked groups both collapsed are both as short as their tabs: the lower one doesn't take +/// the rest of the dock as a blank pane. +#[test] +fn two_stacked_groups_both_collapsed_are_both_as_short_as_their_tabs() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"swatches", "collapsed": true})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"layers", "collapsed": true})).unwrap(); + let h = harness(app, vec2(1280.0, 800.0)); + let groups = root_groups(&h); + assert_eq!(groups.len(), 2, "{groups:?}"); + for group in &groups { + assert!(group.rect.height() < 40.0, "{groups:?}"); + } +} + +fn tab_center(h: &Harness<'static, VectorcraftApp>, panel: &str) -> Pos2 { + h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect.center() +} + +/// A double-click on a tab of the shared dock collapses its group to its tabs; another tab expands it. +#[test] +fn a_double_click_on_a_dock_tab_collapses_its_group_to_its_tabs_and_another_expands_it() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + let saved = app.ui.docking.clone(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let groups = root_groups(&h); + assert_eq!(groups.len(), 2, "{groups:?}"); + let (top, lower) = (groups[0].rect, groups[1].rect); + assert!(top.height() > 100.0, "{top:?}"); + + // The upper group shrinks to its tabs and the lower one takes the room; the saved layout keeps + // its sizes. + let at = tab_center(&h, "swatches"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.contains("swatches")); + let groups = root_groups(&h); + assert!(groups[0].rect.height() < 40.0, "{groups:?}"); + assert!(groups[1].rect.top() < lower.top() - 50.0, "{groups:?}"); + assert_eq!(h.state().ui.docking, saved); + // Again, and it is as tall as before. + let at = tab_center(&h, "swatches"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.is_empty()); + let groups = root_groups(&h); + assert!((groups[0].rect.height() - top.height()).abs() < 2.0, "{groups:?} {top:?}"); + + // The lower group, tabbed: its body goes, and a click on another of its tabs brings it back + // showing that panel. + let at = tab_center(&h, "layers"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.contains("layers")); + assert!(!layers_drawn(&h), "no Layers body"); + let at = tab_center(&h, "properties"); + click(&mut h, at); + assert!(h.state().ui.docking_collapsed.is_empty()); + assert_eq!(root_group_of(h.state(), "properties"), ["properties", "layers", "libraries"]); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +/// A double-click on a tab of the legacy dock collapses the tabbed group to its tabs. +#[test] +fn a_double_click_on_a_legacy_dock_tab_collapses_the_tabbed_group() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + click(&mut h, tab); + h.run_steps(45); + assert!(layers_drawn(&h), "Layers shows"); + double_click(&mut h, tab); + assert!(h.state().ui.docking.is_none(), "still the legacy dock"); + assert!(h.state().ui.docking_collapsed.contains("layers")); + assert!(!layers_drawn(&h), "no Layers body"); + double_click(&mut h, tab); + assert!(h.state().ui.docking_collapsed.is_empty()); + assert!(layers_drawn(&h), "Layers shows again"); +} + +/// `window.panel.collapse` checks its parameters, and a collapsed group survives a reload. +#[test] +fn the_collapse_command_validates_and_survives_a_reload() { + let mut app = app(); + assert_eq!(app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(), json!({"panel":"layers", "collapsed":true})); + assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers, "it shows the panel"); + let saved = serde_json::to_value(&app.ui).unwrap(); + let restored = serde_json::from_value::(saved).unwrap().sanitized(); + assert!(restored.docking_collapsed.contains("layers")); + assert_eq!(app.run("window.panel.collapse", json!({"panel":"layers", "collapsed":false})).unwrap()["collapsed"], false); + for params in [json!({"panel":"nope"}), json!({"panel":"layers", "collapsed":"yes"}), json!({"panel":"swatches"})] { + assert!(app.run("window.panel.collapse", params.clone()).is_err(), "{params}"); + } + app.run("window.panel.float", json!({"panel":"layers", "x":300, "y":300})).unwrap(); + assert!(app.run("window.panel.collapse", json!({"panel":"layers"})).is_err(), "floating groups collapse with float"); + assert!(app.ui.docking_collapsed.is_empty()); +} + +/// A panel moved out of a collapsed group doesn't take the collapse along. +#[test] +fn a_collapsed_groups_panel_moved_elsewhere_takes_no_collapse_along() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(); + // Layers, shown by the collapsed group, goes to the Swatches group and is shown there. + app.run("window.panel.move", json!({"panel":"layers", "anchor":"swatches"})).unwrap(); + app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); + crate::panel_docking::prune_collapsed(&mut app.ui); + assert!(app.ui.docking_collapsed.is_empty(), "neither group is collapsed"); + // The same through a serialized action (a drag). + app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(); + app.run("window.panel.layout", json!({"action": {"Move": {"panel":"layers", "anchor":"properties", "placement": {"Tab": {"before": null}}}}})) + .unwrap(); + assert!(app.ui.docking_collapsed.is_empty()); +} + +/// `window.panel.collapse` refuses a panel that is in an accordion, not a tab group. +#[test] +fn the_collapse_command_rejects_an_accordion_panel() { + let mut app = app(); + app.ui.docking = Some(Layout { + root: Some(Node::Stack { + entries: vec![ + craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: None }, + craft_ui::docking::StackEntry { panel: "swatches".into(), open: true, height: None }, + ], + }), + floating: Vec::new(), + }); + assert!(valid(app.ui.docking.as_ref().unwrap())); + assert!(app.run("window.panel.collapse", json!({"panel":"layers"})).is_err()); + assert!(app.ui.docking_collapsed.is_empty()); +} + +/// Escape on a collapsed group's drag puts it back collapsed. +#[test] +fn escape_on_a_collapsed_groups_drag_leaves_it_collapsed() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + // An icon panel's flyout is open too. + h.state_mut().ui.open_panel = Some("color".into()); + h.run_steps(2); + let groups = root_groups(&h); + let lower = groups.iter().find(|g| g.panels.contains(&"layers")).unwrap().strip; + // The grip right of the lower group's tabs, dragged out of the dock, then Escape. + let from = lower.right_center() - vec2(50.0, 0.0); + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=6 { + h.event(Event::PointerMoved(from - vec2(80.0 * step as f32, 0.0))); + h.run_steps(1); + } + assert!(h.state().ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels.contains(&"layers".into())), "the group floats mid-drag"); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty(), "back in the dock"); + assert!(h.state().ui.docking_collapsed.contains("layers"), "and still collapsed"); + assert_eq!(h.state().ui.open_panel.as_deref(), Some("color"), "the flyout is back"); +} + +/// A group beside another (a left/right split) is refused: it can't be shorter than its neighbour. +#[test] +fn a_group_beside_another_does_not_collapse() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"left"})).unwrap(); + let before = app.ui.docking.clone(); + assert!(app.run("window.panel.collapse", json!({"panel":"swatches"})).is_err()); + assert!(app.run("window.panel.collapse", json!({"panel":"properties"})).is_err()); + assert_eq!(app.ui.docking, before, "refused without a change"); + assert!(app.ui.docking_collapsed.is_empty()); + // One left collapsed by an earlier layout (a hand-edited file) is expanded. + app.ui.docking_collapsed.insert("swatches".into()); + crate::panel_docking::prune_collapsed(&mut app.ui); + assert!(app.ui.docking_collapsed.is_empty()); +} diff --git a/crates/ui-egui/src/panels/blend.rs b/crates/ui-egui/src/panels/blend.rs index 5b723519e5..e258cfbc09 100644 --- a/crates/ui-egui/src/panels/blend.rs +++ b/crates/ui-egui/src/panels/blend.rs @@ -94,6 +94,36 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); + let selected = info["target"] == "blend"; + // Position easing needs a spine to run along: a replaced one, or Align to Path. + let spined = info["spine"]["explicit"] == true || info["orientation"] == "path"; + ui.add_enabled_ui(selected && spined, |ui| { + let modes = ["easeIn", "easeOut", "easeInOut", "easeOutIn"]; + let labels = [tl!("Ease In"), tl!("Ease Out"), tl!("Ease In Out"), tl!("Ease Out In")]; + let mode = info["easingType"].as_str().unwrap_or(modes[0]); + let index = modes.iter().position(|m| *m == mode).unwrap_or(0); + widgets::label_row(ui, tl!("Type:"), LABEL_W, |ui| { + if let Some(i) = widgets::dropdown(ui, "blend-panel-easing", labels[index], &labels, ui.available_width().max(FIELD_W)) + && let Some(mode) = modes.get(i) + { + change = Some(json!({"easingType": mode})); + } + }); + widgets::label_row(ui, tl!("Ramp:"), LABEL_W, |ui| { + let mut ramp = info["ramp"].as_f64().unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut ramp, 0.0..=100.0).suffix("%")).changed() { + change = Some(json!({"ramp": ramp})); + } + }); + }); + ui.add_enabled_ui(selected, |ui| { + widgets::label_row(ui, tl!("Shift:"), LABEL_W, |ui| { + let mut shift = info["colorShift"].as_f64().unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut shift, -100.0..=100.0).suffix("%")).changed() { + change = Some(json!({"colorShift": shift})); + } + }); + }); if let Some(p) = change { app.run("object.blend.options", p).ok(); } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 97f30f54ca..a6959e7c94 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -712,6 +712,24 @@ fn style_toggle(ui: &mut Ui, g: Glyph, tip: &str, on: bool) -> bool { resp.clicked() } +/// The Character Direction submenu: Default, Left to Right and Right to Left set the characters the +/// panel acts on ([`char_cmd`]) as the bidi algorithm sets them, or left to right or right to left +/// inside their paragraph. The direction those characters share is checked, and none is checked +/// when they differ. +fn char_direction_items(app: &mut VectorcraftApp, ui: &mut Ui) { + use vectorcraft_doc::ParaDirection; + let direction = shared(app, |s| s.char_direction); + for (label, d, key) in [ + (tl!("Default"), None, "default"), + (tl!("Left to Right"), Some(ParaDirection::LeftToRight), "leftToRight"), + (tl!("Right to Left"), Some(ParaDirection::RightToLeft), "rightToLeft"), + ] { + if menu_item(ui, label, true, direction == Some(d)) { + format(app, json!({"charDirection": key})); + } + } +} + pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let st = text_style(app); let has = st.is_some(); @@ -795,6 +813,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + ui.add_enabled_ui(has, |ui| { + ui.menu_button(format!(" {}", tl!("Character Direction")), |ui| char_direction_items(app, ui)); + }); ui.separator(); } // No Break: the selected characters stay on one line. @@ -847,8 +868,9 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline -/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off and the Default digits, whether -/// or not Show East Asian Options and Show Indic Options are on. +/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off, the Default character +/// direction and the Default digits, whether or not Show East Asian Options and Show Indic Options +/// are on. fn reset_panel(app: &mut VectorcraftApp) { let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); @@ -857,7 +879,8 @@ fn reset_panel(app: &mut VectorcraftApp) { json!({ "kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false, "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, - "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "digits": d.digits + "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "charDirection": "default", + "digits": d.digits }), ); } @@ -1292,6 +1315,36 @@ mod tests { assert!(shown(&mut app)); } + /// Character Direction is in the panel menu with the Middle Eastern (Indic) options, beside + /// Digits. Its submenu checks the direction the characters the panel acts on share: the + /// selected type's, or the range the Type tool has selected. It checks no item when they differ. + #[test] + fn character_direction_checks_the_direction_of_the_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Direction"); + assert!(!shown(&mut app)); + app.session.prefs.show_indic_options = true; + assert!(shown(&mut app)); + let checked = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, char_direction_items); + text.lines().filter_map(|l| l.strip_prefix("✓ ")).map(str::to_string).collect::>() + }; + assert_eq!(checked(&mut app), ["Default"]); + app.run("text.setRangeStyle", json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + // The object's characters differ: nothing is checked. + assert!(checked(&mut app).is_empty()); + // The Type tool's selection: the date, then the Hebrew word. + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 9, "end": 19})); + assert_eq!(checked(&mut app), ["Left to Right"]); + app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); + assert_eq!(checked(&mut app), ["Default"]); + } + /// No Break is in the panel menu, and choosing it marks the selected characters. #[test] fn no_break_menu_item_toggles_the_selected_characters() { @@ -1321,8 +1374,8 @@ mod tests { /// Reset Panel sets every character attribute the panel shows, except the font family, style /// and size, back to its default (Small Caps, the position, Character Alignment, Proportional - /// Metrics, Tate-chu-yoko, No Break and Digits included), in the selected type and in the text - /// the Type tool has selected while it edits. + /// Metrics, Tate-chu-yoko, No Break, Character Direction and Digits included), in the selected + /// type and in the text the Type tool has selected while it edits. #[test] fn reset_panel_resets_every_attribute_but_the_font() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -1350,6 +1403,7 @@ mod tests { s.proportional_metrics != d.proportional_metrics, s.tate_chu_yoko != d.tate_chu_yoko, s.no_break != d.no_break, + s.char_direction != d.char_direction, s.digits != d.digits, ] }; @@ -1359,16 +1413,17 @@ mod tests { json!({ "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", - "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, "digits": "hindi" + "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, + "charDirection": "rightToLeft", "digits": "hindi" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 17]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 17]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/crates/ui-egui/src/panels/tests_blend.rs b/crates/ui-egui/src/panels/tests_blend.rs index d2b15f7d94..405fe00174 100644 --- a/crates/ui-egui/src/panels/tests_blend.rs +++ b/crates/ui-egui/src/panels/tests_blend.rs @@ -225,3 +225,37 @@ fn each_button_runs_its_blend_command() { let objects = top(&app); assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}"); } + +#[test] +fn panel_easing_type_ramp_and_shift_edit_the_blend_and_undo() { + // Type and Ramp need a spine: Align to Path gives the blend one. + let mut app = app(); + let id = NodeId(app.run("object.blend.make", json!({"smooth": true, "orientation": "path"})).unwrap()["id"].as_u64().unwrap()); + let mut f = Frames::new(); + for label in ["Type:", "Ease In", "Ramp:", "Shift:"] { + f.text(&mut app, label); + } + f.click_text(&mut app, "Ease In"); + f.click_text(&mut app, "Ease Out In"); + assert_eq!(spec(&app, id).ease.kind, vectorcraft_doc::BlendEaseType::OutIn); + // Click each real slider: its command updates the engine, and Undo restores the old value. + for (index, key) in [(0, "ramp"), (1, "colorShift")] { + let out = f.run(&mut app, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().unwrap().nodes; + let mut sliders: Vec<_> = nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Slider).collect(); + // The tree is not in layout order: Ramp sits above Shift. + sliders.sort_by(|a, b| a.1.bounds().unwrap().y0.total_cmp(&b.1.bounds().unwrap().y0)); + let slider = sliders[index]; + let b = slider.1.bounds().unwrap(); + let at = pos2((b.x0 + 0.3 * (b.x1 - b.x0)) as f32, ((b.y0 + b.y1) * 0.5) as f32); + let before = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone(); + f.click(&mut app, at); + let after = app.session.execute("object.blend.info", &json!({})).unwrap()[key].clone(); + assert_ne!(after, before, "{key}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()[key], before); + } + app.run("ui.blendOptions", json!({})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(!text.contains("Ease In:") && !text.contains("Ramp:") && !text.contains("Color Ease"), "{text}"); +} diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 4dcf879b84..704c9058aa 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -22,6 +22,9 @@ pub struct Job { pub struct Done { pub key: CacheKey, + /// The document rendered, and the options it was rendered with. + pub doc: Arc, + pub opts: RenderOptions, pub img: Rendered, pub ms: f64, /// [`vectorcraft_render::FrameStats::moved`] of the frame. @@ -53,7 +56,8 @@ impl Worker { } let t0 = crate::now_ms(); let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts); - if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() { + if done_tx.send(Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() + { break; } ctx.request_repaint(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f28a943881..84475df0a4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -145,6 +145,12 @@ pub fn all_panels() -> impl Iterator { tabs.chain(ICON_PANELS.iter().map(|&(id, label, _)| (id, label))) } +/// Panel `id`'s id, English label and icon if it can live in the icon column: an icon panel, or +/// one of the tabbed group's panels dropped there. +pub fn rail_panel(id: &str) -> Option<(&'static str, &'static str, &'static str)> { + ICON_PANELS.iter().find(|entry| entry.0 == id).copied().or_else(|| DockTab::from_id(id).map(DockTab::info)) +} + /// Panels that live as collapsed icons in the dock (Essentials Classic). pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("color", "Color", "palette"), @@ -363,6 +369,10 @@ pub struct UiState { /// Panels whose saved return destination is the iconic rail. #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] pub docking_icons: std::collections::BTreeSet, + /// The panels shown by dock groups collapsed to their tabs (a double-click on a tab); a group + /// showing another of its panels is expanded. + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub docking_collapsed: std::collections::BTreeSet, /// Invalidates in-flight gestures when a workspace is replaced. #[serde(skip)] pub docking_generation: u64, @@ -428,6 +438,10 @@ pub struct UiState { /// User-saved workspaces (New Workspace…). #[serde(default)] pub custom_workspaces: Vec, + /// Layout changes made in a workspace since it was saved (or last reset), by workspace name: + /// switching back to it brings them back; Reset drops them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub workspace_changes: Vec, /// File → Open Recent Files, most recent first. #[serde(default)] pub recent_files: Vec, @@ -770,7 +784,24 @@ impl UiState { FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos); crate::edit_toolbar::sanitize(&mut self.toolbar_edits); self.docking = self.docking.filter(crate::panel_docking::valid); - self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id)); + // The tabbed group's panels live in the icon column too once dropped there. + self.docking_icons.retain(|id| rail_panel(id).is_some()); + self.docking_collapsed = std::mem::take(&mut self.docking_collapsed) + .into_iter() + .filter(|id| crate::menus::normalize_panel(id) == Some(id.as_str())) + .take(64) + .collect(); + // Changes kept for workspaces that still exist, under their saved spelling and one per + // workspace whatever the case (a hand-edited file). + let mut seen = std::collections::BTreeSet::new(); + let saved: Vec = + self.custom_workspaces.iter().map(|w| w.name.clone()).chain(crate::workspaces::builtins().into_iter().map(|w| w.name)).collect(); + self.workspace_changes.retain_mut(|w| { + let Some(name) = saved.iter().find(|n| n.eq_ignore_ascii_case(&w.name)) else { return false }; + w.name = name.clone(); + seen.insert(name.to_ascii_lowercase()) + }); + self.workspace_changes.truncate(64); // Overrides that can't fire (modifier-only chords recorded by older versions, #487) give // the default back. self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some()); @@ -803,6 +834,7 @@ impl Default for UiState { docking: None, docking_hidden: Default::default(), docking_icons: Default::default(), + docking_collapsed: Default::default(), docking_generation: 0, dock_width: None, dock_width_pending: false, @@ -829,6 +861,7 @@ impl Default for UiState { workspace: default_workspace(), touch_workspace_restore: None, custom_workspaces: vec![], + workspace_changes: vec![], recent_files: vec![], recent_fonts: vec![], favorite_fonts: vec![], diff --git a/crates/ui-egui/src/tests_saveext.rs b/crates/ui-egui/src/tests_saveext.rs index 5272af5ccb..01e9a1d9f1 100644 --- a/crates/ui-egui/src/tests_saveext.rs +++ b/crates/ui-egui/src/tests_saveext.rs @@ -161,6 +161,25 @@ fn the_file_type_picked_in_the_save_dialog_decides_the_format() { } } +/// The suggested name's extension typed in another case still gets the picked type's +/// (`Untitled-1.VECTORCRAFT` with .ai picked is saved as `Untitled-1.ai`), and the picked type's +/// own extension typed in another case stays as typed (`a.AI` with .ai picked). +#[test] +fn the_suggested_names_extension_in_another_case_gets_the_picked_types() { + for (cmd, answer, expected) in [ + ("file.save", &["/art/Untitled-1.VECTORCRAFT", "ai"][..], "/art/Untitled-1.ai"), + ("file.save", &["/art/Untitled-1.VectorCraft", "svg"], "/art/Untitled-1.svg"), + ("file.saveAsTemplate", &["/art/Untitled-1.VCTemplate", "ait"], "/art/Untitled-1.ait"), + ] { + let (mut app, shown, written) = portal_app(); + assert_eq!(saved_from(&mut app, &shown, &written, cmd, answer), expected, "{cmd} {answer:?}"); + } + // A document saved as .ai suggests `a.ai`. + let (mut app, shown, written) = portal_app(); + app.run("file.saveAs", json!({"path": "/art/a.ai", "options": {}})).unwrap(); + assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/a.AI", "ai"]), "/art/a.AI"); +} + /// A name typed with the extension of a format the save does not write has it replaced, with or /// without a picked type: `card.ait` and `card.png` are saved as `card.vectorcraft`, not /// `card.ait.vectorcraft`. An extension that names no format stays part of the name. diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 824ccb9340..46780eab23 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -1,6 +1,11 @@ //! Window → Workspace: saved panel/bar layouts (Essentials, Essentials Classic, Painting, …), //! Reset, New Workspace…, Manage Workspaces…. A workspace is a snapshot of `UiState` layout //! flags; UI brightness and everything else is untouched when switching. +//! +//! As in Illustrator, a workspace remembers the changes made in it: switching to another one keeps +//! the layout as it was left (`UiState::workspace_changes`, kept with the preferences), switching +//! back brings it back, and Reset returns to the workspace as saved. New Workspace… with +//! the name of a user workspace saves the current layout as it. use std::sync::atomic::Ordering; @@ -39,6 +44,9 @@ pub struct Workspace { /// Panels whose saved return destination is the iconic rail. #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] pub docking_icons: std::collections::BTreeSet, + /// The panels shown by dock groups collapsed to their tabs. + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub docking_collapsed: std::collections::BTreeSet, /// Where the Tools panel floats (built-in workspaces: docked). pub toolbar_pos: Option<[f32; 2]>, /// Main dock column width in screen points (#1753). @@ -175,6 +183,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { docking: ui.docking.clone(), docking_hidden: ui.docking_hidden.clone(), docking_icons: ui.docking_icons.clone(), + docking_collapsed: ui.docking_collapsed.clone(), toolbar_pos: ui.toolbar_pos, dock_width: ui.dock_width, } @@ -199,8 +208,8 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.docking_generation = ui.docking_generation.wrapping_add(1); ui.docking = w.docking.clone().filter(crate::panel_docking::valid); ui.docking_hidden = w.docking_hidden.clone(); - ui.docking_icons = - w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect(); + ui.docking_icons = w.docking_icons.iter().filter(|id| crate::state::rail_panel(id).is_some()).take(64).cloned().collect(); + ui.docking_collapsed = w.docking_collapsed.iter().filter(|id| crate::menus::normalize_panel(id) == Some(id.as_str())).take(64).cloned().collect(); ui.toolbar_pos = w.toolbar_pos; ui.dock_width = w.dock_width; ui.dock_width_pending = true; @@ -209,8 +218,37 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.workspace = w.name.clone(); } -/// A workspace by name (user workspaces shadow nothing: names are unique across both lists). -pub fn find(ui: &UiState, name: &str) -> Option { +/// Keep the layout of the current workspace as it is now, so switching back brings it back. +fn remember(ui: &mut UiState) { + // Kept under the saved workspace's spelling, which `find_saved` matches whatever the case. + let Some(saved) = find_saved(ui, &ui.workspace.clone()) else { return }; + let w = capture(ui, &saved.name); + ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(&saved.name)); + ui.workspace_changes.push(w); + // Over the cap, the oldest go: the layout just kept is the newest. + let over = ui.workspace_changes.len().saturating_sub(64); + ui.workspace_changes.drain(..over); +} + +/// Switch to workspace `name` as it was left (its changes since it was saved), else as saved. +pub fn switch(ui: &mut UiState, name: &str) -> Result { + let saved = find_saved(ui, name).ok_or_else(|| format!("unknown workspace (one of {})", names(ui).join(", ")))?; + remember(ui); + let w = ui.workspace_changes.iter().find(|w| w.name.eq_ignore_ascii_case(&saved.name)).cloned().unwrap_or(saved); + apply(ui, &w); + Ok(w.name) +} + +/// Reset the current workspace to the way it was saved, dropping its changes. +pub fn reset(ui: &mut UiState) -> String { + let w = find_saved(ui, &ui.workspace.clone()).unwrap_or_default(); + ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(&w.name)); + apply(ui, &w); + w.name +} + +/// A workspace by name as saved (user workspaces shadow nothing: names are unique across both lists). +pub fn find_saved(ui: &UiState, name: &str) -> Option { ui.custom_workspaces .iter() .find(|w| w.name.eq_ignore_ascii_case(name)) @@ -218,6 +256,11 @@ pub fn find(ui: &UiState, name: &str) -> Option { .or_else(|| builtins().into_iter().find(|w| w.name.eq_ignore_ascii_case(name))) } +/// A workspace by name as saved. +pub fn find(ui: &UiState, name: &str) -> Option { + find_saved(ui, name) +} + /// Every workspace name: built-ins then user workspaces. pub fn names(ui: &UiState) -> Vec { builtins().into_iter().map(|w| w.name).chain(ui.custom_workspaces.iter().map(|w| w.name.clone())).collect() @@ -232,11 +275,15 @@ pub fn save_as(ui: &mut UiState, name: &str) -> Result<(), String> { if is_builtin(name) { return Err(format!("`{name}` is a built-in workspace; choose another name")); } + // The changes go into the workspace saved, which starts with none; the one left keeps the + // changes it had (none of these). + // A user workspace of that name in another case is replaced, as `find_saved` would find it. let w = capture(ui, name); - match ui.custom_workspaces.iter_mut().find(|x| x.name == name) { + match ui.custom_workspaces.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { Some(x) => *x = w, None => ui.custom_workspaces.push(w), } + ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(name)); ui.workspace = name.into(); Ok(()) } @@ -247,8 +294,13 @@ pub fn delete(ui: &mut UiState, name: &str) -> Result<(), String> { if ui.custom_workspaces.len() == before { return Err(if is_builtin(name) { format!("`{name}` is built in and can't be deleted") } else { format!("no workspace `{name}`") }); } + ui.workspace_changes.retain(|w| w.name != name); + // The current workspace deleted: Essentials (as it was left) takes over, rather than its layout + // staying on screen under Essentials's name, to be remembered as Essentials's changes. if ui.workspace == name { - ui.workspace = ESSENTIALS.into(); + let essentials = + ui.workspace_changes.iter().find(|w| w.name == ESSENTIALS).cloned().or_else(|| find_saved(ui, ESSENTIALS)).unwrap_or_default(); + apply(ui, &essentials); } Ok(()) } @@ -260,6 +312,9 @@ pub fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> { } let w = ui.custom_workspaces.iter_mut().find(|w| w.name == from).ok_or_else(|| format!("no user workspace `{from}`"))?; w.name = to.into(); + for w in ui.workspace_changes.iter_mut().filter(|w| w.name == from) { + w.name = to.into(); + } if ui.workspace == from { ui.workspace = to.into(); } @@ -337,18 +392,8 @@ pub fn popup(app: &mut VectorcraftApp, ui: &mut egui::Ui) { pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); let r = match id { - "window.workspace" => match s("name").and_then(|n| find(&app.ui, &n)) { - Some(w) => { - apply(&mut app.ui, &w); - Ok(json!({"workspace": w.name})) - } - None => Err(format!("unknown workspace (one of {})", names(&app.ui).join(", "))), - }, - "window.workspace.reset" => { - let w = find(&app.ui, &app.ui.workspace.clone()).unwrap_or_default(); - apply(&mut app.ui, &w); - Ok(json!({"workspace": w.name})) - } + "window.workspace" => switch(&mut app.ui, &s("name").unwrap_or_default()).map(|name| json!({"workspace": name})), + "window.workspace.reset" => Ok(json!({"workspace": reset(&mut app.ui)})), "window.workspace.new" => match s("name") { Some(n) => save_as(&mut app.ui, &n).map(|_| json!({"workspace": n})), None => { @@ -509,6 +554,126 @@ mod tests { assert_eq!(back.workspace, "Mine"); } + /// Changes made in a workspace come back after switching away and back, and after a restart, until Reset. + #[test] + fn a_workspace_keeps_its_changes_across_switches_until_reset() { + let mut ui = UiState::default(); + save_as(&mut ui, "Görkem").unwrap(); + // Changes made in it after saving (a panel moved, the control bar shown)… + ui.control_bar = true; + ui.dock_tab = DockTab::Layers; + ui.docking_icons.insert("layers".into()); + ui.docking_collapsed.insert("layers".into()); + // …are still there after switching away and back. + switch(&mut ui, ESSENTIALS).unwrap(); + assert!(!ui.control_bar && ui.docking_icons.is_empty() && ui.docking_collapsed.is_empty(), "Essentials comes as saved"); + switch(&mut ui, "Görkem").unwrap(); + assert!(ui.control_bar && ui.dock_tab == DockTab::Layers && ui.docking_icons.contains("layers")); + assert!(ui.docking_collapsed.contains("layers"), "its collapsed group too"); + // Built-in workspaces remember theirs too. + switch(&mut ui, ESSENTIALS).unwrap(); + ui.status_bar = false; + switch(&mut ui, "Görkem").unwrap(); + switch(&mut ui, ESSENTIALS).unwrap(); + assert!(!ui.status_bar); + // They survive a restart (the preferences' JSON round trip). + let mut ui: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); + ui = ui.sanitized(); + switch(&mut ui, "Görkem").unwrap(); + assert!(ui.control_bar); + // Reset Görkem: back to the way it was saved. + assert_eq!(reset(&mut ui), "Görkem"); + assert!(!ui.control_bar && ui.docking_icons.is_empty()); + switch(&mut ui, ESSENTIALS).unwrap(); + switch(&mut ui, "Görkem").unwrap(); + assert!(!ui.control_bar, "a reset workspace stays reset"); + // Renamed, it keeps its changes; deleted, they go. + ui.control_bar = true; + switch(&mut ui, ESSENTIALS).unwrap(); + rename(&mut ui, "Görkem", "G").unwrap(); + switch(&mut ui, "G").unwrap(); + assert!(ui.control_bar); + switch(&mut ui, ESSENTIALS).unwrap(); + delete(&mut ui, "G").unwrap(); + assert!(ui.workspace_changes.iter().all(|w| w.name != "G")); + } + + /// Deleting the current workspace shows Essentials as it was left, not the deleted one's layout. + #[test] + fn deleting_the_current_workspace_switches_to_essentials_without_leaking_its_layout() { + let mut ui = UiState::default(); + save_as(&mut ui, "Tmp").unwrap(); + ui.control_bar = true; + delete(&mut ui, "Tmp").unwrap(); + assert_eq!(ui.workspace, ESSENTIALS); + assert!(!ui.control_bar, "Essentials's layout is shown, not the deleted one's"); + save_as(&mut ui, "Other").unwrap(); + switch(&mut ui, ESSENTIALS).unwrap(); + assert!(!ui.control_bar, "Essentials didn't remember the deleted workspace's layout"); + } + + /// With 64 layouts remembered, the oldest goes and the one just left is kept. + #[test] + fn a_full_list_of_remembered_layouts_drops_the_oldest_not_the_newest() { + let mut ui = UiState::default(); + let names: Vec = (0..70).map(|i| format!("W{i}")).collect(); + for name in &names { + save_as(&mut ui, name).unwrap(); + } + for name in &names { + switch(&mut ui, name).unwrap(); + ui.control_bar = true; + } + switch(&mut ui, ESSENTIALS).unwrap(); + assert_eq!(ui.workspace_changes.len(), 64); + assert!(ui.workspace_changes.iter().all(|w| w.name != "W0"), "the oldest went"); + switch(&mut ui, "W69").unwrap(); + assert!(ui.control_bar, "the layout just left is kept"); + } + + /// Workspace names match whatever their case, as `find` does: "Mine" saved over "mine" replaces + /// it, and changes made under one spelling come back under the other. + #[test] + fn workspace_names_match_whatever_their_case() { + let mut ui = UiState::default(); + save_as(&mut ui, "mine").unwrap(); + save_as(&mut ui, "Mine").unwrap(); + assert_eq!(ui.custom_workspaces.len(), 1, "one workspace, not two spellings"); + ui.control_bar = true; + switch(&mut ui, ESSENTIALS).unwrap(); + assert_eq!(switch(&mut ui, "MINE").unwrap(), "Mine"); + assert!(ui.control_bar, "its changes came back"); + // A preferences file with a remembered layout under another spelling, twice: it maps to the + // saved one, once. + ui.control_bar = false; + switch(&mut ui, ESSENTIALS).unwrap(); + let mut odd = capture(&ui, "MINE"); + odd.control_bar = true; + ui.workspace_changes = vec![odd.clone(), Workspace { name: "mine".into(), ..odd }]; + let mut ui = ui.sanitized(); + assert_eq!(ui.workspace_changes.iter().map(|w| w.name.as_str()).collect::>(), ["Mine"]); + switch(&mut ui, "Mine").unwrap(); + assert!(ui.control_bar); + } + + /// Saving a workspace takes the current layout into it, which then starts with no changes. + #[test] + fn saving_a_workspace_moves_the_changes_into_it() { + let mut ui = UiState { control_bar: true, ..Default::default() }; + save_as(&mut ui, "Mine").unwrap(); + switch(&mut ui, ESSENTIALS).unwrap(); + assert!(!ui.control_bar, "Essentials stays as saved: the changes went into Mine"); + switch(&mut ui, "Mine").unwrap(); + assert!(ui.control_bar); + // Saving again under the same name takes the current layout and starts with no changes. + ui.status_bar = false; + save_as(&mut ui, "Mine").unwrap(); + assert!(ui.workspace_changes.iter().all(|w| w.name != "Mine")); + ui.status_bar = true; + assert_eq!(reset(&mut ui), "Mine"); + assert!(!ui.status_bar); + } + #[test] fn builtin_names_are_protected() { let mut ui = UiState::default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3978a27abe..8b300712d9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -858,6 +858,11 @@ positions of hidden panels. Resetting a built-in workspace restores its default dock placement when possible, or to the supplied destination. - `window.panel.activate {panel}` reveals and selects a panel, restoring its saved position when possible. `window.panel.close {panel}` hides it. +- `window.panel.collapse {panel, collapsed?}` shows the dock group holding a panel as its tabs + only, as a double-click on a tab does (without `collapsed` it toggles), and makes the panel the + group's shown one; showing another of its panels expands the group. Only a group that is the + whole dock or sits above or below another collapses (one beside another is refused). Floating + groups collapse with `window.panel.float {panel, collapsed}`. Returns `{panel, collapsed}`. Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and invalid destinations return an error without changing the arrangement. diff --git a/docs/development.md b/docs/development.md index 44265fbb63..983493ff4e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -390,10 +390,24 @@ The original site's plain HTTP archive is no longer fetched or extracted. To ref review the mirror commit, original-file list, redistribution license, sizes and hashes together; do not replace expected hashes automatically in response to a download mismatch. +## Highlighted-text Eyedropper (2026-10-21) + +Switching from Type to Eyedropper preserves the highlighted character range and its overlay. +Sampling applies enabled font and paint attributes to that range; Shift samples color only. +Returning to Type retains the range; deselection and document changes clear stale ranges. +Character styles cannot represent object opacity or blend modes. With Transparency enabled, +sampling refuses a non-neutral source or target object before changing art, paint defaults, +selection or history. Disable Transparency in Eyedropper Options to sample supported attributes +while preserving the target object's transparency. Whole-object sampling remains unchanged. +Regression evidence: `tests_eyedropper.rs` covers Unicode, font/paint, Undo/Redo, tool switches, +and atomic rejection of source and target opacity/blend modes; `text_range_and_brush_sampling_options_coexist` +covers upstream brush-key sampling. Scores and estimates unchanged. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | | 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features, and the engine declared without default features. | | 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). | | 2026-10-14 | minor | Document registration of every explicitly supplied craft-fonts face after the existing script-specific prefix; empty input and web selection remain unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 385be67bba..70a30158ef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -181,15 +181,20 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall whole layer (#1864, #1683); a failed save or export is reported in a dialog that names the file and stays until closed, the document left modified (#1865, #1685). - **Done:** the Shape Builder offers the empty areas closed shapes enclose as regions, such as the middle of four - circles that touch, and merges them with the regions around them (#1691). Gap Detection is still missing. + circles that touch, and merges them with the regions around them (#1691). +- **Done (#2523):** Shape Builder Gap Detection shares Live Paint's spatially indexed gap finder, + including its overshoot/hook safeguards. The existing Shape Builder Tool Options offers Small (3 pt), + Medium (6 pt), Large (12 pt) or Custom (up to 72 pt). Temporary bridges are used in detection and + merging, but are never added to the art. Off by default; `shapeBuilder.merge` and + `shapeBuilder.regions` take `gap` in points. Other existing options are retained. Clicking a stroke + in merge mode and broader interaction QA remain. + - **Implemented:** deleting an interior anchor of an open path removes its connecting segments and keeps surviving runs separate, without bridging the gap. The remainder stays selected for a second Delete; explicit Remove Anchors still reconnects/refits. Other G11 items and the estimate below remain unchanged. - **Done (#1910):** Transform Each's Reflect X flips left-right and Reflect Y flips top-bottom (aligned with Effect › Distort › Transform). Properties flip buttons keep the reference app's Properties names (Flip Along Horizontal / Vertical Axis). -- **Missing (noted for reporters, 2026-10-14):** deleting an interior anchor of an open path joins across the gap, - where the documented behaviour removes the point and its connecting segments. Inside the estimate below. - **Missing:** Shape Builder misses planar regions and targets whole shapes (#937); Transform effect dialog's invisible checkboxes and missing options (#885); PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). - **Impact:** wrong output in everyday work. @@ -259,6 +264,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done (from users' reports):** Object › Create Gradient Mesh on an embedded image turns it into a mesh over its rectangle, with the image's transform kept; each point takes the average colour and opacity of the picture around it (#1676). A linked image is refused, as the public documentation has it. +- **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, + Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. + The original polynomial distributions approximate the public description. On-canvas easing handles + and their synchronization remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + - **Missing (asked for by users, from earlier versions):** radial gradients in Image Trace's Gradients option, which traces linear ones only (draft #1849). Inside the estimate below. - **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular @@ -375,10 +385,21 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall contrast. Illustrator's own accessibility is limited, so this is mostly beyond parity. - **Estimate:** 15–25 h. +## Highlighted-text Eyedropper (2026-10-21) + +Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled +font/paint attributes. Enabled non-neutral source or target object transparency is refused +atomically; disable Transparency to preserve object transparency while sampling the range. +`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key +sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | +| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | +| 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top. No entry closed, estimates unchanged. | | 2026-10-20 | minor | G1: for the selection tools, a press within the selection tolerance of a clipping path's outline (a clip group's, or a layer's clipping mask) hits the clipping path before the art it clips, so Direct Selection takes its segments and the Selection tool the clip group even where the art lies over the mask's edge; Select Behind lists the same; the eyedropper and the tools that act on what is drawn are unchanged. Doc and tool regression tests. No entry closed, estimates unchanged. | @@ -393,8 +414,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas reprojection preserves scale during resize, rotation and DPI changes. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically. Documentation correction: #1692 was completed upstream before #2008; the Direct Selection alignment gap remains open. Scores and hours unchanged. | +| 2026-10-18 | minor | G11: Shape Builder Gap Detection (#2523): Shape Builder Tool Options with a Gap Length of Small, Medium, Large or Custom bridges small gaps between open paths for the regions they bound; the other Shape Builder options stay missing. The stale gap line on deleting an interior anchor of an open path is removed (the behaviour was already implemented). Estimates unchanged | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them; a Top-handle regression confirms scaling of the default repeat. Center and end handles retain their move and rotation behavior. G1 scope and estimate unchanged. | +| 2026-10-19 | minor | G13: blend easing (#1124): Ease In / Ease Out and separate Color Ease In / Out in Blend Options, saved with the blend; on-canvas handles and easing in the Blend panel remain. Estimate unchanged | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | G12: Background Export progress dialog with Cancel and Continue in Background (#2564); no entry closed, estimates unchanged | | 2026-10-16 | minor | Native documents restore transparency grid visibility (#1968); save/open regression covers on/off, old files and SVG; scores and hours unchanged (estimated) | diff --git a/docs/mcp.md b/docs/mcp.md index 84962bea77..4c6bf9adba 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1349,6 +1349,9 @@ already walks Image Trace. Short agent entry points: `livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as `livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces. + `livePaint.fill` does not fill a face that is hidden on its own. With `group`, a point on a hidden face is an + error. With `ids`, at a point on a hidden face it fills the face there of another Live Paint group among the + objects; otherwise it returns an error and does not merge the objects. `brush.selectionArt {ids?|id?}` (a query) replies `{art}`: the object that `brush.new` given the same `ids` or `id`, or the same selection, takes as a new scatter or art brush's art and as a new pattern brush's side tile. Several @@ -1535,7 +1538,13 @@ Recolor Artwork keys colours by identity, not hex: `recolor.colors` lists each c Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key. `recolor.reduce {colors?, method?, preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use; tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows -`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?, group?, groupColors?, rename?}`. +`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, model?, limitTo?, group?, groupColors?, rename?}`. +For scripts and MCP, `model` selects the replacement color model: `keep` (default) retains each +original color's model, `new` takes the replacement's model, and `document` converts replacements +and edited color groups to the document's model. Invalid values fail before editing. This parameter +is not a control in the Recolor Artwork dialog. A returned `note` reports a model conversion in +`keep` mode. + `method` says how a row's colours take its new colour: `exact`, `preserveTints` (tints of the row's darkest colour stay tints), `scaleTints` (every colour becomes a tint as light, relative to the darkest), `tintsShades` (lighter and darker than the row's average become tints and shades) or `hueShift` (the most saturated colour takes the new colour, @@ -4135,6 +4144,25 @@ explicitly, `{}` for none. Each command is one undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"fontAxes":{"wght":650,"wdth":87.5}}}} ``` +## Character Direction + +`text.setFormat {charDirection: "default"|"leftToRight"|"rightToLeft"}` (the selected type, or `ids`) and +`text.setRangeStyle {id, start, end, charDirection}` (a range) set the direction of the characters inside their +paragraph, as the Character panel menu's Character Direction does with the Middle Eastern (Indic) options shown. The bidi +algorithm (UAX #9) resolves each stretch of characters with a direction as an embedding in that direction, as if an LRE +or RLE stood before it and a PDF after it: `leftToRight` keeps a date written with spaces, such as `21 10 2026`, in its +order inside a right-to-left paragraph, and `rightToLeft` sets a Hebrew or Arabic word and the punctuation after it right +to left inside a left-to-right paragraph. Characters keep their own strong direction inside an embedding: Latin letters +set Right to Left still read left to right. `default` leaves the characters to the bidi algorithm. The paragraph's own +direction is unchanged. SVG export writes type with a character drawn right to left, or with a run set Right to Left, +as outlines; live SVG text writes `direction="ltr"` and `unicode-bidi="embed"` on each run set Left to Right. Character +Direction is Default until set and is saved only when set, and character styles captured from type carry it. Each +command is one undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":9,"end":19,"charDirection":"leftToRight"}}} +``` + ## Digits `text.setFormat {digits: "default"|"arabic"|"hindi"|"farsi"}` (the selected type, or `ids`) and `text.setRangeStyle {id, diff --git a/docs/roadmap.md b/docs/roadmap.md index aafc8f1f66..6bcf19c7d2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; M12 duplicate milestone rows consolidated without losing distinct details; scores and estimates unchanged; live-trace bounds note placed under Image Trace rather than Graphs; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; M3.57: CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; DXF circular cubic paths export as native CIRCLE/ARC with R12 weighted strokes kept as POLYLINE and closing-chord safeguards (#1299); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 graph pruning and junction merging measured; M12 exact crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; M14: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); estimates unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; M12 duplicate milestone rows consolidated without losing distinct details; scores and estimates unchanged; live-trace bounds note placed under Image Trace rather than Graphs; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; M3.57: CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; DXF circular cubic paths export as native CIRCLE/ARC with R12 weighted strokes kept as POLYLINE and closing-chord safeguards (#1299); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 graph pruning and junction merging measured; M12 exact crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -52,12 +52,12 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | -| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; auto/slider/advanced UI remains open); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | +| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; auto/slider/advanced UI remains open); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Photocopy, Stamp, Torn Edges and Water Paper done (54/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | @@ -66,7 +66,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -83,10 +83,26 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 M3 follow-up (#1968): native saves retain the per-document transparency grid setting; save/open regression covers both states, older files and SVG. Scores and estimates unchanged. +## Highlighted-text Eyedropper (2026-10-21) + +Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled +font/paint attributes. Enabled non-neutral source or target object transparency is refused +atomically; disable Transparency to preserve object transparency while sampling the range. +`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key +sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | +| 2026-10-21 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset (UI.7); estimates unchanged | +| 2026-10-21 | minor | M14: panels dock by dragging into any dock group (tabs or a top/bottom split) or the icon column; dock tabs close from their context menu (UI.6); estimates unchanged | +| 2026-10-21 | minor | M14: a double-click on a dock tab collapses its group to its tabs and expands it again (UI.8); estimates unchanged | +| 2026-10-20 | minor | M14: workspaces keep the layout changes made in them across switches and restarts until Reset (UI.7); estimates unchanged | +| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | +| 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | +| 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-20 | minor | Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; existing opening gestures retained; scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 04fe6d78ea..7a1e3e66c0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth and drawn, with SVG/PDF outlines where ruby is present; scores and hours unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; G4: System Layout; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth and drawn, with SVG/PDF outlines where ruby is present; scores and hours unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; G4: System Layout; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -71,8 +71,8 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de |---|---:|---:|---|---:|---| | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; remaining: Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated | | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners' Relative rounding; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered | -| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937), its gap detection and options; Live Paint Gap Options; Simplify slider/auto, advanced UI and Show Original | 4–8 | estimated (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Divide open-cutting-path geometry and appearance regression-tested (#1307) | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) | +| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options; Simplify slider/auto, advanced UI and Show Original | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Divide open-cutting-path geometry and appearance regression-tested (#1307) | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; remaining: Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 46/50, depth estimated; Warp Options controls verified by focused checks and original-image review | @@ -80,7 +80,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 49% | 3 Photoshop-style filters (Sketch › Conté Crayon among them), the Effect Gallery dialog, Load Texture | 8–14 | measured count 54/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch › Torn Edges added); texture redraw distances optimized (#1257), pixels verified; depth estimated | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -196,7 +196,7 @@ Depth here is "does the basic use work", not the area's full score. | Shape tools (rectangle, ellipse, polygon, star, line) | 9 | 95% | all shape tools, Live Corners | | Selection, move, scale, rotate | 9 | 92% | bounding box, smart guides, transform tools | | Pen and Pencil, basic use | 7 | 88% | [ui-parity.md](ui-parity.md); a few Pencil and Curvature options missing | -| Fill, stroke, colour, swatches | 9 | 95% | M3 done | +| Fill, stroke, colour, swatches | 9 | 95% | M3 done; highlighted-text Eyedropper font/paint and source/target transparency refusal regression-tested; scores and hours unchanged | | Gradients, basic | 4 | 90% | Gradient panel and on-canvas annotator | | Type: point and area text, font, size | 8 | 88% | font menu with previews; hyphenation options missing (rarely touched) | | Align, group, arrange | 6 | 95% | | @@ -350,8 +350,8 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 13 Preferences options stored but not yet acted on (#394: Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 14 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes, the canvas context menu and docking panels into any dock group by dragging (as tabs, a top/bottom split or icons) are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window, context menus beyond the canvas, about 13 Preferences options stored but not yet acted on (#394: Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes, the canvas context menu and docking panels into any dock group by dragging (as tabs, a top/bottom split or icons) are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window, context menus beyond the canvas, about 14 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 | | Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745), its search, groups and library export/import as `.vclibrary` files (#926), and a list that scrolls, with items and groups reordered by dragging and items dragged into and out of groups, are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | | **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | @@ -486,7 +486,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get a name for the Tools panel's tool buttons, flyout rows, floating strips and tear-off bars, and for the icon buttons, text buttons, toggles, checkboxes, radio buttons, tabs, chips, sliders, angle dials and combo boxes that panels and dialogs draw with `crates/ui-egui/src/widgets.rs`: the text, label or tool tip each one shows, or a name of its own where it shows none (the angle dial, the spinner, the preset arrow, the color wheel and the Reference Point's squares). The tool buttons, toggles, checkboxes, radio buttons, tabs and the Reference Point's nine squares also report whether they are on, an icon button that is lit, such as the chosen Align To button, reports that it is pressed, and the angle dial reports its angle. The status bar's First, Previous, Next and Last Artboard buttons have tool tips. - These dialog sliders, each beside a number field, respond to the keyboard: those of the effect, tool option, brush and transform dialogs, the Perspective Grid, the 3D dialogs, and the flattener options, which the Flattener Preview panel also shows. A click, or Tab, gives a slider the keyboard focus, and an outline shows it. Then → and ↑ step the field's number up to the next whole number and ← and ↓ step it down to the next whole number, Shift with an arrow and Page Up and Page Down step it by ten, and Home and End set the slider's first and last value; no step goes past either end. Screen readers' increment and decrement actions step the slider as → and ← do. The Color panel's, Swatch Options', the Navigator's and the Character panel's sliders, and the Color Picker's channel slider, respond only to the pointer. The keys follow the WAI-ARIA slider pattern (); the reference app's help does not describe keys on a dialog's slider, so the step sizes are VectorCraft's own. - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. + - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`, `every_undo_label_is_translated`). @@ -683,6 +683,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. + The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting. In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`). With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. @@ -704,6 +705,10 @@ 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). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). + Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. + The four polynomial distributions are original approximations of the public description, not measured + numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + - Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged. Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. @@ -778,7 +783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`). - macOS: the save sheet of Save As, Save a Copy, Save as Template and the first Save of a new document has a Format menu that lists each format the save can write, with the suggested file's format selected. Choosing a format changes the extension in the name field, and a name typed without an extension gets the chosen format's. A name typed with another listed extension is saved in that format. The menu is set up before the sheet opens; a save panel with one file type is shown by rfd as before (#866, `apps/vectorcraft/src/mac_save_format.rs`). - - Linux and the BSDs: the save dialog of Save As, Save a Copy, Save as Template and the first Save of a new document is asked of the desktop portal, starting on the suggested file's type, and the file type picked in it decides the format. A name typed without an extension, or with the suggested file's, gets the picked type's extension; a name typed with another save format's extension (for Save as Template, a template format's) is saved in that format, another format's extension is replaced with the picked type's, and any other extension gets the picked type's added. rfd shows the dialog when the portal can't (#2679, `apps/vectorcraft/src/portal_save.rs`). + - Linux and the BSDs: the save dialog of Save As, Save a Copy, Save as Template and the first Save of a new document is asked of the desktop portal, starting on the suggested file's type, and the file type picked in it decides the format. A name typed without an extension, or with the suggested file's in any letter case, gets the picked type's extension; a name typed with another save format's extension (for Save as Template, a template format's) is saved in that format, another format's extension is replaced with the picked type's, and any other extension gets the picked type's added. rfd shows the dialog when the portal can't (#2679, `apps/vectorcraft/src/portal_save.rs`). - Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`). Save as Template always writes a file named with its template type's extension: `file.saveAsTemplate` saves a path ending in another format's extension (`card.png`) as `card.vctemplate` (or `card.ait`), the desktop save panel does the same with a Save As format's extension typed in it (`card.pdf` is saved as `card.vctemplate`), and `file.saveAs` and `document.save` refuse `ait` with an error that names `file.saveAsTemplate`. A `.ait` is PDF-compatible only: `aiVersion: "7"` is an error for it, and the web build's dialog shows no Version row for it. Exports refuse a `.ait` with an error that names `file.saveAsTemplate`, and `vectorcraft-cli convert in.pdf out.ait` and `run --export out.ait` write the template as `file.saveAsTemplate` does. @@ -919,10 +924,26 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs match main byte for byte; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost. +## Highlighted-text Eyedropper (2026-10-21) + +Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled +font/paint attributes. Enabled non-neutral source or target object transparency is refused +atomically; disable Transparency to preserve object transparency while sampling the range. +`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key +sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation. Scores and estimates unchanged. | +| 2026-10-21 | minor | UI chrome: workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout (UI.7); score and hours unchanged (estimated) | +| 2026-10-21 | minor | UI chrome: a panel group dragged onto a dock group's tabs joins it, onto its upper or lower half makes a new group above or below it, onto the icon column becomes icons (the tabbed group's panels too); dock tabs close from their context menu (UI.6); score and hours unchanged (estimated) | +| 2026-10-21 | minor | UI chrome: a double-click on a dock tab collapses its group to its tabs and expands it again, as floating groups already did (UI.8); scores and hours unchanged (estimated) | +| 2026-10-20 | minor | UI chrome: workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout (UI.7); score and hours unchanged (estimated) | +| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | +| 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | +| 2026-10-21 | minor | Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls. Scores and estimates unchanged. | | 2026-10-21 | minor | SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts. Scores and estimates unchanged. | | 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding | | 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged | @@ -947,8 +968,10 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect | 2026-10-18 | minor | File formats: PDF invisible text (rendering mode 3, a scanned page's recognized text) opens as type with no fill or stroke instead of being dropped without a warning, and characters with no fill or stroke export as invisible text, so a scanned PDF opened and saved keeps its searchable text (#2675); own-fixture tests; scores and hours unchanged (estimated) | | 2026-10-19 | minor | Effect rasters cropped to the view or made coarser are kept while the view stays the same: a drag over a 500 pt blur draws it from the still frame on this thread and on the worker, and an interaction stays on the worker that drew the frame shown (#2558). Scores and hours unchanged. | | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. | +| 2026-10-18 | minor | Shape Builder: Gap Detection (Small 3 pt, Medium 6 pt, Large 12 pt, Custom) in the new Shape Builder Tool Options bridges small gaps between open paths for the regions they bound (#2523); Path operations stays 80% (estimated) while #937 is open | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. Headless canvas regression confirms vertical scaling without width change; scores and hours unchanged. | +| 2026-10-19 | minor | Blends: ease in/out and separate colour easing in Blend Options (#1124); on-canvas handles and easing in the Blend panel remain. Symbols, blends, envelopes score unchanged (estimated) | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | Evidence: Artboard Options has X and Y beside a reference point locator, counted from the ruler origin (#1876); scores unchanged | | 2026-10-17 | minor | Image Trace: Auto Color keeps rare but distinct colours drawn as regions of their own (stems, highlights, near-white on pale backgrounds), at most six, protected from mixed-colour merging and Noise: every key colour on 66 of 66 flat test logos (was 39); scans unchanged. Scores and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a867e7b755..93e6fda8c6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144; interactive Mirror Repeat axis; Background Export progress dialog with Cancel, #2564; scores unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144; interactive Mirror Repeat axis; Background Export progress dialog with Cancel, #2564; scores unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -169,6 +169,7 @@ tools, never checked side by side) isn't captured row by row. | Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | | | Document window scroll bars along the right and bottom edges (Normal and Full Screen Mode with Menu Bar; none in Full Screen and Presentation Mode): a thumb as long as the view against the art and the artboards; dragging it scrolls, a click on the track pages, the wheel over a bar scrolls along it; in a rotated view they follow the screen's axes | partial | #2322. The span is the art and the artboards with half a window around them, not Illustrator's 227 in canvas: VectorCraft has no bounded drawing area (#2284). A track click pages rather than jumping to the click; the page size and the rotated-view behaviour are our own choice (the public documentation gives neither) | | Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | done | Cmd+Alt lends Group Selection only while Direct Selection is active (Cmd borrow from the Pen keeps Direct Selection so Alt stays copy/subtract); engine pointer regressions | +| Eyedropper styles highlighted characters only and preserves ranges across Type switches | done | Font/paint, Shift color-only, Unicode, Undo/Redo and stale-range regression tests; enabled non-neutral source or target object transparency is refused atomically; disable Transparency to sample supported attributes | | Eyedropper reads visible sources while their layer/object is locked or outside isolation | done | Ordinary/Shift/Shift+Alt and gradient-stop reads preserve the source and selection; Alt reverse remains editable-only; hidden art stays excluded (`eyedropper_sources.rs`, #1251) | | Add a visible stroke after drawing with inherited None/zero-width paint | done | Paint and Swap Fill/Stroke restore the existing 1 pt default; positive widths and painted zero widths stay as set; Undo/Redo and targeted appearance items tested (`stroke_visibility.rs`, #1253) | | Double-click a stroked segment to create a width point in Width Point Edit | done | Finite-arithmetic follow-up: engine regressions and full CI passed; [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area). Command and pointer regressions cover opening, sampled widths, adjoining adjustment on/off through checkbox/OK, compound paths, Cancel/Delete and Undo/Redo; dialog layout reviewed. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | @@ -222,6 +223,8 @@ empty), without opening the dialog. | ~40 panels with ≡ menus; dock, icon column, collapse, tabs | done | 51 panel modules incl. tests | | Panels float inside the window, stack, dock back | done | #495 | | Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` | +| Panels dragged onto a dock group's tabs join it; onto its upper or lower half they make a new group above or below it (a top/bottom split, resizable); onto the icon column they become icons, the tabbed group's panels too; a dock tab's context menu closes it (Window menu reopens it) | done | `window.panel.dock {anchor, zone}`, `window.panel.dock {icon: true}`, `window.panel.close` | +| A double-click on a dock group's tab collapses the group to its tabs (a group below it moves up into the room) and a second one expands it; a click on another of its tabs expands it showing that panel ([helpx: Workspace basics](https://helpx.adobe.com/illustrator/using/workspace-basics.html), panel groups) | done | `window.panel.collapse {panel, collapsed?}`; in the legacy dock and in the shared dock's tree (floating groups already did); a group beside another (a left/right split) doesn't collapse | | Panels outside the app window (another monitor) | missing | single OS window | | Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; New/Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) | | Quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the editable controls | done | Per the public documentation, choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or in Properties' Image Trace section (). The controls show Threshold, Paths, Corners and Noise with Advanced open; `panels/image_trace.rs` headless tests (`a_quick_trace_leaves_the_panel_as_it_was`, `sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source` for the button's route). No algorithm parity claim. | @@ -241,9 +244,9 @@ empty), without opening the dialog. | 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: 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 | +| 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) | | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | -| Workspaces (save, reset, switch) | done | | +| Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset | done | `UiState::workspace_changes` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch and graphic style library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library::`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | | 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 | @@ -267,10 +270,25 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # - [x] Compact Color Picker with a distinct header, stacked actions and aligned input groups. #2008; translated action widths, visible warnings, stable mode switching and vertical scrolling in narrow windows; real egui events and two UI scales covered; German and enlarged English renders reviewed locally. Scores unchanged +## Highlighted-text Eyedropper (2026-10-21) + +Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled +font/paint attributes. Enabled non-neutral source or target object transparency is refused +atomically; disable Transparency to preserve object transparency while sampling the range. +`tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key +sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | +| 2026-10-21 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout; score unchanged (estimated) | +| 2026-10-21 | minor | Panels dragged by the mouse join a dock group as tabs, split it top/bottom or become icons in the icon column; dock tabs close from their context menu; score unchanged (estimated) | +| 2026-10-21 | minor | A double-click on a dock tab collapses its group to its tabs, as floating groups already did, and expands it again; another of its tabs expands it (`window.panel.collapse`); scores unchanged (estimated) | +| 2026-10-20 | minor | Workspaces keep the layout changes made in them when switching away and back, and across restarts; Reset returns to the saved layout; score unchanged (estimated) | +| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | +| 2026-10-21 | minor | Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top (public doc cited in target-app-parity.md). Scores unchanged. | | 2026-10-19 | minor | Distribute Objects and Distribute Spacing space the selected ruler guides across the axis with the objects, as items with no size, and two or more guides selected alone (#1597 in part); with a key guide and a spacing, the guide stays put and the other items are spaced from it (key guide from #3165); the Distribute Spacing value is enabled for a key guide; scores unchanged | @@ -286,6 +304,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | 2026-10-19 | minor | Color Picker layout (#2008): compact controls, centered header, stacked actions and aligned inputs; screen-reader channel values, current shared button names and styling, screen sampler and model-by-model Tab order retained. Narrow windows scroll vertically; scores and hours unchanged. | | 2026-10-18 | minor | Mirror Repeat shows its axis when selected (#2877): drag the line or center handle to move it, drag an end handle to rotate it (Shift: 45°), Escape cancels and each drag is one undo step. Engine and headless canvas gesture regressions passed; native move, rotation and Escape screenshots reviewed. Formatting, Clippy, WASM and repository checks passed; the undo label reuses translated Repeat Options. (#2877) Scores and hours unchanged. | | 2026-10-18 | minor | Bounding-box handles take priority where a selected Mirror Repeat axis crosses them: the Top handle scales the repeat, while the center still moves it and the ends still rotate it. A headless canvas regression confirms height changes while width stays fixed; scores and hours unchanged. | +| 2026-10-19 | minor | Blend Options: Ease In / Ease Out and Color Ease In / Out sliders with live preview (#1124); the Blend panel row stays partial (no easing in the panel, no on-canvas handles) | | 2026-10-17 | minor | Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged. Windows native canvas checks and screenshots reviewed in PR #2643. | | 2026-10-17 | minor | Artboard Options has X and Y beside a reference point locator, counted from the ruler origin as the Transform panel's are; a typed X or Y moves the artboard's reference point there and a new Width or Height keeps it in place (#1876); scores unchanged (estimated) | | 2026-10-17 | minor | Background Export shows a progress dialog with Cancel and Continue in Background (#2564), built on the shared cancel token of #2546; scores and hours unchanged (estimated) | diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000000..dc74b9bd09 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_vectorcraft +sonar.organization=storytold