From b5301ca74d0d926e6750ebc23e3bb287fedee872 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 18:24:56 -0800 Subject: [PATCH 01/10] Fix range text stroke weight defaults and scaling --- crates/engine/src/cmd/textedit.rs | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 8ce750a5e1..df19ec0023 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -351,6 +351,8 @@ pub(crate) struct CharChange { stroke: Option, /// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke. stroke_opts: StrokeChange, + /// Document points per text-space point for a stroke edit. + stroke_scale: Option, underline: Option, strikethrough: Option, all_caps: Option, @@ -641,6 +643,7 @@ impl CharChange { fill: paint_param(p, "fill", cmd)?, stroke: paint_param(p, "stroke", cmd)?, stroke_opts, + stroke_scale: None, underline: flag("underline"), strikethrough: flag("strikethrough"), all_caps: flag("allCaps"), @@ -723,6 +726,7 @@ impl CharChange { self.tcy_up_down = self.tcy_up_down.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?; self.tcy_left_right = self.tcy_left_right.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?; self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?; + self.stroke_scale = Some(vectorcraft_doc::Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0)); Ok(()) } @@ -809,10 +813,13 @@ impl CharChange { st.fill = v.clone(); } if let Some(v) = &self.stroke { + if !v.is_none() { + super::gradient::run_stroke_weight(st); + } st.stroke = v.clone(); } if !self.stroke_opts.is_empty() { - self.stroke_opts.apply_char(st); + self.stroke_opts.apply_char_scaled(st, self.stroke_scale.unwrap_or(1.0)); } if let Some(v) = self.underline { st.underline = v; @@ -1609,3 +1616,27 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result { s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?; Ok(json!({"removed": true})) } + +#[cfg(test)] +mod range_stroke_tests { + use super::*; + + #[test] + fn range_stroke_gains_a_default_weight_and_scales_explicit_weight() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ABCD"})).unwrap()["id"].as_u64().unwrap(); + let weights = |s: &Session| { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text missing") }; + t.runs.iter().map(|r| r.style.stroke_width).collect::>() + }; + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "stroke": "#ff0000"})).unwrap(); + assert_eq!(weights(&s), [1.0, 0.0], "a newly painted range has a visible default stroke"); + + s.execute("object.scale", &json!({"ids": [id], "sx": 200, "sy": 200})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "strokeWidth": 2.0})).unwrap(); + assert_eq!(weights(&s), [1.0, 0.0], "two document points become one text-space point at 200% scale"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": 4, "stroke": "#0000ff", "strokeWidth": 4.0})).unwrap(); + assert_eq!(weights(&s), [1.0, 2.0], "a different range receives its scaled weight"); + } +} From 79102f61043108a8ecfd82b21887402600adb3bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 20:52:01 -0800 Subject: [PATCH 02/10] Tests: range stroke weight test joins the character stroke tests The test moves from an inline module in cmd/textedit.rs to tests/charstroke.rs and uses that file's helpers. docs/mcp.md says that text.setRangeStyle gives a range's stroke a 1 pt weight as text.setStyle does, and that its strokeWidth and strokeOptions lengths are in document points. --- crates/engine/src/cmd/textedit.rs | 24 ------------------------ crates/engine/src/tests/charstroke.rs | 15 +++++++++++++++ docs/mcp.md | 2 ++ 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index df19ec0023..ac0af58671 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1616,27 +1616,3 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result { s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?; Ok(json!({"removed": true})) } - -#[cfg(test)] -mod range_stroke_tests { - use super::*; - - #[test] - fn range_stroke_gains_a_default_weight_and_scales_explicit_weight() { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); - let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ABCD"})).unwrap()["id"].as_u64().unwrap(); - let weights = |s: &Session| { - let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text missing") }; - t.runs.iter().map(|r| r.style.stroke_width).collect::>() - }; - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "stroke": "#ff0000"})).unwrap(); - assert_eq!(weights(&s), [1.0, 0.0], "a newly painted range has a visible default stroke"); - - s.execute("object.scale", &json!({"ids": [id], "sx": 200, "sy": 200})).unwrap(); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "strokeWidth": 2.0})).unwrap(); - assert_eq!(weights(&s), [1.0, 0.0], "two document points become one text-space point at 200% scale"); - s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": 4, "stroke": "#0000ff", "strokeWidth": 4.0})).unwrap(); - assert_eq!(weights(&s), [1.0, 2.0], "a different range receives its scaled weight"); - } -} diff --git a/crates/engine/src/tests/charstroke.rs b/crates/engine/src/tests/charstroke.rs index ba948cb664..2c34e697ea 100644 --- a/crates/engine/src/tests/charstroke.rs +++ b/crates/engine/src/tests/charstroke.rs @@ -91,6 +91,21 @@ fn range_stroke_options_split_runs() { assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "strokeOptions": 3})).is_err()); } +#[test] +fn range_stroke_gains_a_default_weight_and_scales_explicit_weight() { + let mut s = session(); + let id = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "ABCD"}))["id"].as_u64().unwrap()); + let weights = |s: &Session| text(s, id).runs.iter().map(|r| r.style.stroke_width).collect::>(); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "stroke": "#ff0000"})); + assert_eq!(weights(&s), [1.0, 0.0], "a newly painted range has a visible default stroke"); + + run(&mut s, "object.scale", json!({"ids": [id.0], "sx": 200, "sy": 200})); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "strokeWidth": 2.0})); + assert_eq!(weights(&s), [1.0, 0.0], "two document points become one text-space point at 200% scale"); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 2, "end": 4, "stroke": "#0000ff", "strokeWidth": 4.0})); + assert_eq!(weights(&s), [1.0, 2.0], "a different range receives its scaled weight"); +} + #[test] fn eyedropper_and_outlines_carry_the_character_stroke() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 8a2551ffc6..864a1af921 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2404,6 +2404,8 @@ dash?, dashOffset?, alignDashes?}}`. `text.setStyle {ids?, stroke?, strokeWidth? and weight of the characters of each object given (without `start` and `end`: all of its text), with its other keys in one undo step; `strokeWidth` is in document points, as `stroke.set` gives the weight of scaled type. A `stroke` color without `strokeWidth` gives characters that have no stroke weight 1 pt, as `paint.setStroke` does. +`text.setRangeStyle` does the same for its range, and its `strokeWidth` and `strokeOptions` lengths are in document +points too. `align: "inside"|"outside"` keeps the characters' stroke on that side of the glyph outlines, drawn at twice the weight and clipped, while the type stays editable (beyond Illustrator); SVG export writes the text live and that stroke as glyph outlines over it. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first From 0dba3afea9a371bc34c957654cca464a78f6402c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:38:46 -0800 Subject: [PATCH 03/10] Docs: the 1 pt default stroke weight on type is in the type's own units docs/mcp.md says that the default weight text.setStyle and text.setRangeStyle give an unweighted stroke is in the type's own units, so scaled type draws it wider, while strokeWidth and strokeOptions lengths are in document points. --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 864a1af921..5fab5134a2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2405,7 +2405,7 @@ and weight of the characters of each object given (without `start` and `end`: al one undo step; `strokeWidth` is in document points, as `stroke.set` gives the weight of scaled type. A `stroke` color without `strokeWidth` gives characters that have no stroke weight 1 pt, as `paint.setStroke` does. `text.setRangeStyle` does the same for its range, and its `strokeWidth` and `strokeOptions` lengths are in document -points too. +points too. The 1 pt default of both commands is in the type's own units, so on type scaled to 200% it draws 2 pt wide. `align: "inside"|"outside"` keeps the characters' stroke on that side of the glyph outlines, drawn at twice the weight and clipped, while the type stays editable (beyond Illustrator); SVG export writes the text live and that stroke as glyph outlines over it. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first From bf58f605fe5fda2411ea9ced539af81384b5d840 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:53:50 +0530 Subject: [PATCH 04/10] Text Tweaker: refuse ranges that aren't whole bytes, make no step when nothing changes (#4924) A `start` or `end` such as -1 or 1.5 was read as absent and changed text before the range; it is now refused. Characters that already have the value are left out, so a run that changes nothing returns count 0 and makes no undo step. docs/mcp.md cites the public formatting-type page. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/addons/typetools.rs | 35 +++++++++++++++++-- .../engine/src/cmd/addons/typetools/tests.rs | 22 ++++++++++-- docs/mcp.md | 3 +- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 0cd8e21ea1..29ca6f6bfd 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -89,29 +89,58 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result { let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); let skip = switch_or(p, "skipSpaces", true, C)?; let setup = s.doc()?.doc.setup.clone(); + // A range given as anything but whole bytes is refused, not read as all of the text. + for k in ["start", "end"] { + if p.get(k).is_some_and(|v| !v.is_null() && v.as_u64().is_none()) { + return Err(bad(C, format!("`{k}` must be a whole number of bytes"))); + } + } // At a caret, all of the object's text. let ranges = type_ranges(s, p, C, |text, _| Some((0, text.len())))?; // Each object's changed characters: (byte, its change), worked out before the edit. let mut plans: Vec<(NodeId, Vec<(usize, CharChange)>)> = vec![]; - let mut count = 0; + let (mut seen, mut count) = (0, 0); for (id, a, b, _) in ranges { let t = text_ref(s, id)?; let text = t.plain_text(); let chars: Vec = text.char_indices().filter(|&(i, c)| (a..b).contains(&i) && !(skip && (c.is_whitespace() || c.is_control()))).map(|(i, _)| i).collect(); - count += chars.len(); - if count > MAX_CHARS { + seen += chars.len(); + if seen > MAX_CHARS { return Err(bad(C, format!("too many characters: at most {MAX_CHARS}"))); } + // Each run's end byte, to find the style a character has now. + let ends: Vec = t + .runs + .iter() + .scan(0, |off, r| { + *off += r.text.len(); + Some(*off) + }) + .collect(); let n = chars.len(); let mut changes = Vec::with_capacity(n); + let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; let value = from + (to - from) * amount(shape, i, n, random); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; + while ends.get(run).is_some_and(|&e| e <= at) { + run += 1; + } + // A character that already has the value is left out, so a run that changes nothing + // makes no step. + if let Some(r) = t.runs.get(run) { + let mut style = r.style.clone(); + change.apply(&mut style); + if style == r.style { + continue; + } + } changes.push((at, change)); } + count += changes.len(); if !changes.is_empty() { plans.push((id, changes)); } diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index 47a1b92b9d..fec04f9c63 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -62,7 +62,8 @@ fn a_straight_ramp_sizes_each_letter_and_skips_spaces() { select(&mut s, &[id]); let n = steps(&s); let out = s.execute(C, &json!({"attribute": "size", "from": 10, "to": 40})).unwrap(); - assert_eq!(out, json!({"count": 4, "ids": [id.0]})); + // `b` is 20 pt already: three characters change. + assert_eq!(out, json!({"count": 3, "ids": [id.0]})); assert_eq!(steps(&s), n + 1, "one undo step"); let sizes: Vec<(char, f64)> = styles(&s, id).into_iter().map(|(c, st)| (c, st.size)).collect(); assert_eq!(sizes, [('a', 10.0), ('b', 20.0), (' ', 20.0), ('c', 30.0), ('d', 40.0)], "the space keeps its size"); @@ -108,7 +109,8 @@ fn each_object_gets_its_own_ramp() { let (a, b) = (text(&mut s, "abc"), text(&mut s, "de")); select(&mut s, &[a, b]); let out = s.execute(C, &json!({"attribute": "rotation", "from": 0, "to": 30})).unwrap(); - assert_eq!(out["count"], json!(5)); + // Each first character is upright already. + assert_eq!(out["count"], json!(3)); let turns = |id| styles(&s, id).iter().map(|(_, st)| st.rotation).collect::>(); assert_eq!((turns(a), turns(b)), (vec![0.0, 15.0, 30.0], vec![0.0, 30.0])); } @@ -161,6 +163,19 @@ fn empty_text_and_only_spaces_change_nothing() { assert_eq!(steps(&s), n, "no step"); } +#[test] +fn characters_that_already_have_the_value_are_left_out() { + let mut s = session(); + let id = text(&mut s, "abc"); + select(&mut s, &[id]); + let p = json!({"attribute": "rotation", "from": 0, "to": 30}); + // The first character already has 0°. + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 2, "ids": [id.0]})); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 0, "ids": []}), "again: nothing to change"); + assert_eq!((s.doc().unwrap().doc.clone(), steps(&s)), (before, n), "no step"); +} + #[test] fn bad_params_are_refused_without_a_change() { let mut s = session(); @@ -179,6 +194,9 @@ fn bad_params_are_refused_without_a_change() { json!({"seed": -1}), json!({"skipSpaces": "yes"}), json!({"ids": [999_999]}), + json!({"id": id.0, "start": -1}), + json!({"id": id.0, "start": 1, "end": 1.5}), + json!({"end": "2"}), ] { assert!(s.execute(C, &p).is_err(), "{p}"); assert_eq!(s.doc().unwrap().doc, before, "{p}"); diff --git a/docs/mcp.md b/docs/mcp.md index 5fab5134a2..4036c1c1bc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6838,7 +6838,8 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la Type Tools is a built-in add-on for type: its command is `addon.type.textTweaker`, and in the desktop app it lives in Object › Plug-ins › Type Tools (#4924). Text Tweaker gives one character attribute to the characters letter by letter, from a start value to an end value along a shape, as one undo step; the text stays live and editable. Each value is set -as `text.setRangeStyle` sets it (the Character panel), in its units and ranges, so on scaled type it is the size shown. +as `text.setRangeStyle` sets it (the Character panel), in its units and ranges ([formatting type](https://helpx.adobe.com/illustrator/using/formatting-type.html)), so on scaled +type it is the size shown. It changes the range of `id` (`start`/`end` bytes), else all the text of the type objects in `ids`, else the text the Type tool has selected (at a caret: all of that object's text), else all the text of every selected type object; each object gets its own ramp, and at most 100000 characters change in one run. A run that changes nothing makes no step. From 5c224a0d0810c4786638d09da4901bfb708c4380 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:01:27 -0800 Subject: [PATCH 05/10] Symbols: Break Link keeps the instance's opacity mask Break Link to Symbol dropped the instance's opacity mask. The art now keeps it, or a group around the art holds it when the symbol's art has a mask of its own. The objects in the instance's mask art count against Break Link's limit of 1,000,000 objects. --- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/symbols.rs | 16 +++++-- crates/engine/src/tests/brushsym.rs | 53 +++++++++++++++++++++++ docs/mcp.md | 2 +- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index adfb221ef7..f34ae619d6 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -201,7 +201,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}", has_selection, symbol_break_link ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 9a3606311a..534b51506e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -341,7 +341,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { /// The plain art of instance `inst` in document space (fresh ids), with the instance's own /// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice -/// guides when it has them. +/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it +/// when the art has a mask of its own. fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; @@ -361,13 +362,21 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { art.opacity *= inst.opacity; art.blend = inst.blend; } + if let Some(mask) = &inst.extras().mask { + if art.extras().mask.is_some() { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.extras_mut().mask = Some(mask.clone()); + } Some(art) } /// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its /// symbol's object count with the art of its opacity masks, or /// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus -/// the group that opacity or a blending mode adds. 0 for any other object. +/// the group that opacity, a blending mode or the instance's opacity mask adds, plus the art of +/// the instance's opacity mask. 0 for any other object. fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 }; let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 }; @@ -378,7 +387,8 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64, None => sym.art.count_with_masks() as u64, }; - most.saturating_add(1) + let mask = inst.extras().mask.as_ref().map_or(0, |m| m.art.count_with_masks() as u64); + most.saturating_add(1).saturating_add(mask) } /// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index f14ba2b6a8..c1f33ac974 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1242,6 +1242,59 @@ fn break_link_then_update_redefines_all_instances() { assert!((bounds(&s, other).center().x - 300.0).abs() < 1e-6); } +/// A linked opacity mask of a 10 × 10 square at (100, 50). +fn square_mask(s: &mut Session) -> Box { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let art = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 110.0, 60.0)), Default::default()); + Box::new(vectorcraft_doc::OpacityMask::new(art, true)) +} + +/// Break Link to Symbol keeps the instance's opacity mask on the art. +#[test] +fn break_link_keeps_the_instances_opacity_mask() { + let mut s = session(); + let (inst, _, _) = make_symbol(&mut s); + let m = square_mask(&mut s); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(inst).unwrap().extras_mut().mask = Some(m.clone()); + let r = s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap(); + let art = node(&s, NodeId(r["ids"][0].as_u64().unwrap())); + assert!(matches!(art.kind, NodeKind::Path { .. }), "{}", art.kind_label()); + assert_eq!(art.extras().mask, Some(m), "the art keeps the mask"); +} + +/// When the symbol's art has an opacity mask of its own, Break Link puts the instance's mask on a +/// group around the art, and the art keeps its own. +#[test] +fn break_link_puts_the_instances_mask_on_a_group_around_masked_art() { + let mut s = session(); + let (inst, name, _) = make_symbol(&mut s); + let (own, m) = (square_mask(&mut s), square_mask(&mut s)); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sym = doc.symbols.iter_mut().find(|s| s.name == name).unwrap(); + std::sync::Arc::make_mut(&mut sym.art).extras_mut().mask = Some(own); + doc.node_mut(inst).unwrap().extras_mut().mask = Some(m.clone()); + let r = s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap(); + let art = node(&s, NodeId(r["ids"][0].as_u64().unwrap())); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group around the art: {}", art.kind_label()) }; + assert_eq!(art.extras().mask, Some(m), "the group holds the instance's mask"); + assert!(children.len() == 1 && children[0].extras().mask.is_some(), "the art keeps its own mask"); +} + +/// The art of the instance's opacity mask counts against Break Link's object limit: an instance +/// whose mask holds more than a million objects stays an instance. +#[test] +fn break_link_counts_the_instances_mask_art() { + let mut s = session(); + let (inst, _, _) = make_symbol(&mut s); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let square = + std::sync::Arc::new(Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 110.0, 60.0)), Default::default())); + let art = Node::group(doc.alloc_id(), vec![square; 1_000_000]); + doc.node_mut(inst).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + assert!(s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).is_err(), "too many objects"); + assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }), "it stays an instance"); +} + #[test] fn delete_duplicate_and_replace_symbols() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 4036c1c1bc..131acd9be1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1603,7 +1603,7 @@ already walks Image Trace. Short agent entry points: a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; dragging a guide on the canvas in symbol editing mode sends `nineSliceGuides` in one undo step; `type` is Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog), - `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}`, `symbol.replace`, + `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask), `symbol.replace`, `symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard `artboard`, the active one the app passes, or in a document without artboards on `viewCenter`, the view's center the From f581aa8a569ba6726b563bfa0f96da5a8973b439 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:07:02 -0800 Subject: [PATCH 06/10] Select: the font Same commands take their reference from the type on a selected layer A layer that was selected (for example with select.set) gave no reference to Select > Same > Font Family, Font Family & Style, Font Family, Style & Size, Font Size and the text fill and stroke color commands: the layer itself is not selectable, so the type on it was never reached and the command returned "select a text object". A selected layer or sublayer now gives the type on it that can be selected. A hidden, locked or template layer gives none. --- crates/engine/src/cmd/docmenu.rs | 17 +++++++++++----- crates/engine/src/tests/menucmds.rs | 30 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 413e8c401a..40f528d55b 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -328,8 +328,9 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool) /// Call `f` with each type object among the selected objects and inside them, in selection order, /// keeping only those [`walk_selectable`] visits: none that is hidden, locked, on a hidden, locked -/// or template layer, or outside the isolated group. A type object inside two selected objects is -/// given twice. The walk stops when `f` returns false. +/// or template layer, or outside the isolated group. A selected layer or sublayer gives the type +/// on it that passes the same test. A type object inside two selected objects is given twice. The +/// walk stops when `f` returns false. fn each_selected_text(s: &Session, mut f: impl FnMut(NodeId) -> bool) { fn inside(n: &Node, f: &mut impl FnMut(NodeId) -> bool) -> bool { if !n.visible || n.locked { @@ -340,12 +341,18 @@ fn each_selected_text(s: &Session, mut f: impl FnMut(NodeId) -> bool) { } n.children().into_iter().flatten().all(|c| inside(c, f)) } + fn from(n: &Node, selectable: &impl Fn(NodeId) -> bool, f: &mut impl FnMut(NodeId) -> bool) -> bool { + if n.is_layer() { + return n.children().into_iter().flatten().all(|c| from(c, selectable, f)); + } + !selectable(n.id) || inside(n, f) + } let Some(st) = s.active() else { return }; let locks = st.doc.locked_artboards(); + let selectable = |id| is_selectable(&st.doc, st.isolation, &locks, id); for id in &st.selection.objects { - if is_selectable(&st.doc, st.isolation, &locks, *id) - && let Some(n) = st.doc.node(*id) - && !inside(n, &mut f) + if let Some(n) = st.doc.node(*id) + && !from(n, &selectable, &mut f) { return; } diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 18ffe69cb9..bdcbc28e2a 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1995,6 +1995,36 @@ fn select_same_font_skips_type_that_cannot_be_selected() { assert_eq!(selected(&s), vec![g]); } +/// A selected layer gives the type on it that can be selected as the reference of the font Same +/// commands: a hidden or locked layer gives none. +#[test] +fn select_same_font_takes_its_reference_from_the_type_on_a_selected_layer() { + let mut s = session(); + let typed = + |s: &mut Session, y: f64, font: &str| id_of(&s.execute("text.create", &json!({"x": 0, "y": y, "text": "type", "font": font})).unwrap()); + let arial = typed(&mut s, 20.0, "Arial"); + let courier = typed(&mut s, 60.0, "Courier"); + let l2 = id_of(&s.execute("layer.new", &json!({"top": true})).unwrap()); + let on_layer = typed(&mut s, 100.0, "Courier"); + sel(&mut s, &[l2]); + assert_eq!(s.execute("select.same.fontFamily", &json!({})).unwrap()["count"], 2); + let mut got = selected(&s); + got.sort(); + let mut want = vec![courier, on_layer]; + want.sort(); + assert_eq!(got, want); + assert!(!selected(&s).contains(&arial)); + for (off, on) in [ + (json!({"id": l2.0, "visible": false}), json!({"id": l2.0, "visible": true})), + (json!({"id": l2.0, "locked": true}), json!({"id": l2.0, "locked": false})), + ] { + s.execute("layer.setProps", &off).unwrap(); + sel(&mut s, &[l2]); + assert!(s.execute("select.same.fontFamily", &json!({})).is_err(), "{off}"); + s.execute("layer.setProps", &on).unwrap(); + } +} + /// Select › Same › Text Fill & Stroke Color selects the type with characters whose fill and stroke /// both match the first character of the selected type: the same fill with another stroke, or the /// same stroke with another fill, is left out, and in type with several runs one run has to match From c54c4567c77500704266d3e2ff784554fab02679 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 20:22:42 -0800 Subject: [PATCH 07/10] Color controls: preserve working RGB components in numeric edits Read RGB and HSB numbers in the active working RGB space in the Color panel and Color Picker. Keep native values through hex and web-safe controls and cache live components by native color and color settings. --- crates/ui-egui/src/dialogs/color_picker.rs | 18 +- crates/ui-egui/src/menus/ui_commands.rs | 4 +- crates/ui-egui/src/panels/color.rs | 45 +++-- .../ui-egui/tests/color_working_components.rs | 155 ++++++++++++++++++ docs/mcp.md | 5 + docs/target-app-parity.md | 1 + 6 files changed, 206 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/tests/color_working_components.rs diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 60c037597b..8d58f21b41 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -23,7 +23,9 @@ use vectorcraft_engine::cmd::color_value; use super::form::PREVIEWED; use super::{DialogAction, DialogSpec}; -use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe}; +use crate::panels::color::{ + GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe, working_hsb, working_rgb, +}; use crate::panels::{c32, color_json}; use crate::state::Dialog; use crate::theme::Tokens; @@ -123,8 +125,9 @@ pub struct Picked { } impl Picked { + /// Read HSB from the color's native working RGB components. pub fn of(color: Color) -> Self { - Self { color, hsb: color.to_hsb() } + Self { color, hsb: working_hsb(&color) } } fn from_hsb(hsb: [f32; 3]) -> Self { Self { color: Color::from_hsb(hsb[0], hsb[1], hsb[2]), hsb } @@ -137,7 +140,7 @@ impl Picked { /// The slider value and the field point (x right, y down; 0..1 each) of a colour. pub fn field_pos(ch: Channel, p: &Picked) -> (f32, f32, f32) { - let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { p.color.to_rgb() }; + let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { working_rgb(&p.color) }; let (z, x, y) = ch.axes(); (v[z], v[x], 1.0 - v[y]) } @@ -162,7 +165,7 @@ pub fn slider_color(ch: Channel, z: f32, x: f32, y: f32) -> Color { /// The number and suffix that [`value_fields`] shows in the field of `ch` for `p`, and the field's /// largest number: H in degrees (to 360), S and B in percent (to 100), and R, G and B from 0 to 255. fn channel_value(ch: Channel, p: &Picked) -> (f32, &'static str, f32) { - let [r, g, b] = p.color.to_rgb(); + let [r, g, b] = working_rgb(&p.color); match ch { Channel::Hue => (p.hsb[0], "°", 360.0), Channel::Saturation => (p.hsb[1] * 100.0, "%", 100.0), @@ -174,6 +177,7 @@ fn channel_value(ch: Channel, p: &Picked) -> (f32, &'static str, f32) { } /// The colour the dialog holds: an edited `hex` wins over `color` (agents set either one). +/// Cached HSB is reused when it reconstructs working RGB within 1e-6 per channel. fn picked(d: &Dialog) -> Picked { let hex = d.str("hex"); let color = match parse_hex(&hex) { @@ -182,7 +186,9 @@ fn picked(d: &Dialog) -> Picked { }; let hsb = d.fields.get("__hsb").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()); let p = match hsb { - Some(h) if Picked::from_hsb(h).color.to_hex() == color.to_hex() => Picked { color, hsb: h }, + Some(h) if working_rgb(&Picked::from_hsb(h).color).into_iter().zip(working_rgb(&color)).all(|(a, b)| (a - b).abs() <= 1e-6) => { + Picked { color, hsb: h } + } _ => Picked::of(color), }; p.snapped(d.bool("webOnly")) @@ -493,7 +499,7 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool { /// K, with a stop at each channel's radio button before its field. fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option, Option) { let t = Tokens::get(ui.ctx()); - let rgb = p.color.to_rgb(); + let rgb = working_rgb(&p.color); let lab = p.color.to_lab(); let cms = cms::active(); let cmyk = p.color.to_cmyk_managed(cms.settings().intent); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 92bd0ed742..a3b12f4b96 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -717,7 +717,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorPicker", "Color Picker…", "", - "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", + "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); RGB, HSB and hex values use the working RGB space; OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", ), ( "ui.sampleScreenColor", @@ -1092,7 +1092,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorPanel", "Color Panel Options", "", - "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", ), ( "ui.savePdfDialog", diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 00e32808a5..643a7ff661 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -16,6 +16,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_color::{Color, GradientKind, Paint}; use super::{active_paint, color_json, live_run, pstate, set_pstate}; @@ -102,7 +103,19 @@ impl Mode { } } -/// Displayed component values of `c` in `mode` (RGB 0–255, HSB °/%/%, CMYK %, K %, Lab L*a*b*). +/// The color's numeric components in the active working RGB space. +pub(crate) fn working_rgb(c: &Color) -> [f32; 3] { + let cms = cms::active(); + cms.convert(c, cms::Model::Rgb, cms.settings().intent).to_rgb_uncalibrated() +} + +/// HSB of the working RGB components, with hue in degrees. +pub(crate) fn working_hsb(c: &Color) -> [f32; 3] { + let hsb = egui::ecolor::Hsva::from_rgb(working_rgb(c)); + [hsb.h * 360.0, hsb.s, hsb.v] +} + +/// Numeric component values of `c` in `mode` (working RGB 0 to 255, HSB °/%/%, CMYK %, K %, Lab L*a*b*). pub fn components(mode: Mode, c: &Color) -> Vec { match mode { Mode::Grayscale => { @@ -115,9 +128,9 @@ pub fn components(mode: Mode, c: &Color) -> Vec { }; vec![k * 100.0] } - Mode::Rgb | Mode::WebSafe => c.to_rgb().iter().map(|v| (v * 255.0).clamp(0.0, 255.0)).collect(), + Mode::Rgb | Mode::WebSafe => working_rgb(c).iter().map(|v| (v * 255.0).clamp(0.0, 255.0)).collect(), Mode::Hsb => { - let [h, s, b] = c.to_hsb(); + let [h, s, b] = working_hsb(c); vec![h, s * 100.0, b * 100.0] } Mode::Cmyk => c.to_cmyk().iter().map(|v| v * 100.0).collect(), @@ -146,15 +159,16 @@ pub fn from_components(mode: Mode, v: &[f32]) -> Color { } } -/// The nearest web-safe colour (each channel a multiple of 0x33). +/// The nearest web-safe color (each working RGB channel a multiple of 0x33). pub fn web_safe(c: &Color) -> Color { let q = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0; - let [r, g, b] = c.to_rgb(); + let [r, g, b] = working_rgb(c); Color::rgb(q(r), q(g), q(b)) } +/// Whether the working RGB hex value uses only web-safe channel levels. pub fn is_web_safe(c: &Color) -> bool { - web_safe(c).to_hex() == c.to_hex() + hex_digits(&web_safe(c)) == hex_digits(c) } /// Track colour of slider `i` at position `t` (0..1) with the other components fixed (dynamic @@ -190,9 +204,10 @@ pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color { } } -/// The hex field text of a colour (`E67828`). +/// The hex field text of a color's working RGB components (`E67828`). pub fn hex_digits(c: &Color) -> String { - c.to_hex().trim_start_matches('#').to_uppercase() + let [r, g, b] = working_rgb(c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + format!("{r:02X}{g:02X}{b:02X}") } /// Parse a hex field (`E67828`, `#e67828`, `fff`). @@ -220,7 +235,7 @@ pub(crate) fn warning_chip(ui: &mut Ui, (icon, warning): (&str, &str), fix: &Col widgets::swatch_tile(ui, r, &Paint::solid(*fix), false, resp.hovered()); resp.on_hover_text(crate::i18n::fmt( tl!("{warning}: click to correct to the closest color ({hex})"), - &[("warning", tl!(warning)), ("hex", &fix.to_hex())], + &[("warning", tl!(warning)), ("hex", &format!("#{}", hex_digits(fix).to_ascii_lowercase()))], )) .clicked() }) @@ -575,6 +590,7 @@ fn spectrum(ui: &mut Ui, mode: Mode, size: egui::Vec2) -> Option { Some(SpectrumHit::Pick(spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height()), phase)) } +/// Draw numeric paint controls, retaining live components while the color and settings match. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let tgt = target(app, ui); @@ -597,9 +613,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } return; } - // Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.). - let key = color.map(|c| (c.to_hex(), mode as u8)); - let comps: Vec = match (color, pstate::)>>(ui.ctx(), "color-comps")) { + // Keep the components while the native color and settings are unchanged (hue survives S = 0). + let settings = cms::active_settings(); + let key = color.map(|c| (c, mode as u8, settings.clone())); + let comps: Vec = match (color, pstate::)>>(ui.ctx(), "color-comps")) { (Some(_), Some((k, v))) if Some(&k) == key.as_ref() => v, (Some(c), _) => components(mode, &c), (None, _) => vec![0.0; mode.labels().len()], @@ -737,7 +754,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { apply_tint(app, &tgt, tint, t, phase); } if let Some((c, phase, comps2)) = new { - let key = (c.to_hex(), mode as u8); + let key = (c, mode as u8, settings); set_pstate(ui.ctx(), "color-comps", Some((key, comps2))); choose(app, &tgt, c, phase, false); } @@ -779,7 +796,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Copy Color Value (Hex)"), color.is_some(), false) && let Some(c) = color { - ui.ctx().copy_text(c.to_hex()); + ui.ctx().copy_text(format!("#{}", hex_digits(&c).to_ascii_lowercase())); } } diff --git a/crates/ui-egui/tests/color_working_components.rs b/crates/ui-egui/tests/color_working_components.rs new file mode 100644 index 0000000000..85f392416b --- /dev/null +++ b/crates/ui-egui/tests/color_working_components.rs @@ -0,0 +1,155 @@ +//! Numeric color controls edit the working RGB channels, including colors outside display sRGB. +// Integration tests may unwrap and panic (AGENTS.md, Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Mutex, MutexGuard}; + +use serde_json::json; +use vectorcraft_color::Color; +use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, PROPHOTO_RGB, SRGB, WIDE_GAMUT_RGB}; +use vectorcraft_engine::Session; +use vectorcraft_ui_egui::VectorcraftApp; +use vectorcraft_ui_egui::panels::color::{Mode, components, from_components, hex_digits, parse_hex, web_safe}; + +// This integration binary contains only these tests; its app-wide CMS is independent of other +// test binaries. Serialize its cases and restore settings even when an assertion fails. +static CMS: Mutex<()> = Mutex::new(()); + +struct WorkingSpace { + previous: ColorSettings, + _lock: MutexGuard<'static, ()>, +} + +impl WorkingSpace { + /// Select a working profile for one case without retaining it after a panic. + fn new(rgb: &str) -> Self { + let lock = CMS.lock().unwrap_or_else(|e| e.into_inner()); + let previous = cms::active_settings(); + cms::set_active(&ColorSettings { rgb: rgb.into(), ..ColorSettings::default() }).unwrap(); + Self { previous, _lock: lock } + } +} + +impl Drop for WorkingSpace { + fn drop(&mut self) { + cms::set_active(&self.previous).unwrap(); + } +} + +/// Compare native channel values without converting them through the display profile. +fn native(color: Color, expected: [f32; 3]) { + let Color::Rgb { r, g, b } = color else { panic!("expected RGB, got {color:?}") }; + for (actual, expected) in [r, g, b].into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-6, "{color:?}: expected {expected}, got {actual}"); + } +} + +/// Editing R preserves the stored G and B components. +#[test] +fn rgb_edits_preserve_untouched_working_channels() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let color = Color::rgb(0.5, 0.3, 0.2); + let mut values = components(Mode::Rgb, &color); + for (actual, expected) in values.iter().zip([127.5, 76.5, 51.0]) { + assert!((actual - expected).abs() < 1e-4, "{profile}: {values:?}"); + } + values[0] = 153.0; + native(from_components(Mode::Rgb, &values), [0.6, 0.3, 0.2]); + } +} + +/// HSB describes the native RGB triplet; changing hue preserves its saturation and brightness. +#[test] +fn hsb_edits_preserve_native_saturation_and_brightness() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let mut values = components(Mode::Hsb, &Color::rgb(0.5, 0.3, 0.2)); + for (actual, expected) in values.iter().zip([20.0, 60.0, 50.0]) { + assert!((actual - expected).abs() < 1e-4, "{profile}: {values:?}"); + } + values[0] = 140.0; + native(from_components(Mode::Hsb, &values), [0.2, 0.5, 0.3]); + } +} + +/// Native primaries survive numeric round trips even when display conversion clips their gamut. +#[test] +fn numeric_roundtrips_preserve_out_of_display_gamut_colors() { + { + let _space = WorkingSpace::new(DISPLAY_P3); + let red = Color::rgb(1.0, 0.0, 0.0); + let red_with_green = Color::rgb(1.0, 0.05, 0.0); + assert_eq!(red.to_rgb(), red_with_green.to_rgb(), "display clipping loses their native distinction"); + native(from_components(Mode::Rgb, &components(Mode::Rgb, &red_with_green)), [1.0, 0.05, 0.0]); + } + for profile in [DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + for rgb in [[1.0, 0.2, 0.0], [0.0, 1.0, 0.2], [0.2, 0.0, 1.0]] { + let color = Color::rgb(rgb[0], rgb[1], rgb[2]); + for mode in [Mode::Rgb, Mode::Hsb] { + native(from_components(mode, &components(mode, &color)), rgb); + } + } + } +} + +/// Opening the picker reads HSB from the stored RGB components. +#[test] +fn picker_initialization_preserves_working_values() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("ui.colorPicker", json!({"color": [0.5, 0.3, 0.2]})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + let hsb = dialog.fields.get("__hsb").unwrap().as_array().unwrap(); + for (actual, expected) in hsb.iter().zip([20.0, 0.6, 0.5]) { + assert!((actual.as_f64().unwrap() - expected).abs() < 1e-4, "{profile}: {hsb:?}"); + } + } +} + +/// Shared hex and web-safe numeric controls round the native working RGB components. +#[test] +fn shared_hex_and_websafe_preserve_working_values() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let color = Color::rgb(0.5, 0.3, 0.2); + assert_eq!(hex_digits(&color), "804D33", "{profile}"); + native(parse_hex("804D33").unwrap(), [128.0 / 255.0, 77.0 / 255.0, 0.2]); + native(web_safe(&color), [0.6, 0.4, 0.2]); + } +} + +/// A panel slider click changes one native component and makes one undo step. +#[test] +fn color_panel_slider_preserves_native_channels_and_undo() { + use egui_kittest::kittest::Queryable; + + let _space = WorkingSpace::new(DISPLAY_P3); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.setFill", json!({"color": [0.5, 0.3, 0.2]})).unwrap(); + let mut fonts = false; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(320.0, 400.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + vectorcraft_ui_egui::theme::install_fonts(ui.ctx()); + return; + } + vectorcraft_ui_egui::panels::color::show(app, ui); + }, + app, + ); + harness.run_steps(3); + harness.get_by_role_and_label(egui::accesskit::Role::Slider, "R").click(); + harness.run_steps(3); + let color = harness.state().session.proxy_paints().0.color().unwrap(); + let Color::Rgb { r, .. } = color else { panic!("expected RGB") }; + assert_ne!(r, 0.5, "the slider was edited"); + native(color, [r, 0.3, 0.2]); + harness.state_mut().run("edit.undo", json!({})).unwrap(); + native(harness.state().session.proxy_paints().0.color().unwrap(), [0.5, 0.3, 0.2]); +} diff --git a/docs/mcp.md b/docs/mcp.md index 131acd9be1..f8f38ede3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -410,6 +410,11 @@ paint command (and the eyedropper) feeds. `paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy). +In the app's Color panel and Color Picker, RGB, HSB, hex and Web Safe RGB values describe the active working RGB +space. RGB edits preserve the other native channels; HSB uses those same working RGB components. Colors outside +display sRGB retain their native numeric values. `ui.colorPanel` selects the panel mode and `ui.colorPicker` +opens the picker through `run_command` with the remote backend. + | Tool | Arguments | Notes | |---|---|---| | `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b564d9fad..4dd2258510 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1022,6 +1022,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recolor Artwork layout (#3416):** the Edit chip list uses the modal scroll helper and its actual remaining height; the tab's minimum-height space scrolls with the chips. `tests_recolor` covers 5/20/200/400 colors, window resizing, 100/125/150/200% UI scaling, scrolling to and editing the final chip, Preview, Cancel, Confirm and Undo, alongside the existing #1733 drag regressions. Scores and estimates unchanged. - **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged. - **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them. + - **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13. From d9cfaf9041d628950bb574eccd1b0f7fc3ff26fa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:32:18 -0800 Subject: [PATCH 08/10] MCP docs: vertical text uses vert alternates, and duplicate defaults to the current set add_text's vertical option turns on the OpenType vert feature, so the docs no longer say it leaves glyph orientation unchanged. ui.actionSet.duplicate uses the current set when set is omitted, with or without action. --- docs/mcp.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index f8f38ede3a..fb898da5f9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -450,8 +450,9 @@ For example, create editable Japanese area type with the engine's vertical writi ``` `vertical` also applies to point type and to `path` + `mode` (`area` or `onPath`). Omit it or pass `false` -for horizontal writing. This is the same option as `text.create` / `text.createInPath`; it does not change -the engine's existing glyph orientation or CJK composition. +for horizontal writing. This is the same option as `text.create` / `text.createInPath`. Vertical text turns +on the OpenType `vert` feature, so a font's vertical glyph alternates are used; it does not change the engine's +CJK composition. For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text). @@ -1810,8 +1811,8 @@ already walks Image Trace. Short agent entry points: not restore a deleted set. `ui.actionSet.duplicate {set?: index, action?: index}` copies the action at `action` in set `set` to right after it in the same set, and returns `{set, action}`; without `action` it copies the set with all its actions to right after - it, by default the current set, and returns `{set}`. The copy is named after the original with " copy" added, a - copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error. The + it, and returns `{set}`. In both cases `set` defaults to the current set. The copy is named after the original with + " copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error. The panel menu's Duplicate runs this for the selected action, else the current set. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace From 6a8e381dfac0f486ed5038d791e7570b9f41cbfb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:26:28 -0800 Subject: [PATCH 09/10] Actions: New Action leaves its set as the only selected set New Action in the current set of a Shift-selected range kept the whole range selected. After the new action was deleted, Delete Selection asked about every set of the range. The set recorded into is now the only selected set, as it is when Record starts a recording. --- crates/ui-egui/src/panels/actions.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 8948fbef2e..bb3293a203 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -211,7 +211,7 @@ pub const NEW_ACTION: &str = "ui.action.new"; /// recording into it; Stop Playing/Recording ends the recording. `key` (1 to 15, F1 to F15; 0 or /// null for none) with `cmd` (Ctrl, Command on macOS) and `shift` is the function key that plays /// it, and `color` (one of [`COLORS`]; null or `""` for none) its Button Mode color. The action's -/// function key takes priority over command shortcuts. The set becomes current and the new action is selected. While an +/// function key takes priority over command shortcuts. The set becomes current and the only selected set, and the new action is selected. While an /// action is recording, and for an index past the last set, an empty name, a key or color that is /// not one of them, nothing is added. The New Action dialog's Record runs this. → {set, action} pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result { @@ -235,10 +235,8 @@ pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result s.actions.push(action); let index = s.actions.len() - 1; start_recording(app, set, name, Some(id)); - if app.ui.action_set != Some(set) { - app.ui.action_set = Some(set); - app.ui.action_sets_selected = vec![set]; - } + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; if let Some(ctx) = app.ui_ctx.clone() { set_pstate::>(&ctx, "actions-selected", Some((set, index))); } @@ -1406,6 +1404,21 @@ mod tests { assert_eq!(pstate::>(&ctx, "actions-selected"), None, "the action is no longer selected"); } + /// New Action in the current set of a Shift-selected range leaves that set as the only selected + /// one, so Delete Selection does not ask about the rest of the range afterwards. + #[test] + fn new_action_in_the_current_set_clears_the_selected_range() { + let mut app = app_with_sets(); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + click_set(&ctx, &mut app, "Mine", egui::Modifiers::NONE); + click_set(&ctx, &mut app, "Other", egui::Modifiers::SHIFT); + assert_eq!((app.ui.action_set, app.ui.action_sets_selected.clone()), (Some(1), vec![1, 2])); + app.run(NEW_ACTION, json!({"set": 1, "name": "Fresh"})).unwrap(); + assert_eq!((app.ui.action_set, app.ui.action_sets_selected.clone()), (Some(1), vec![1])); + assert_eq!(selected_sets(&app), [1]); + } + /// The default set can be deleted, and so can the last set: the panel is then empty, no set is /// current, and New Set makes a set again. #[test] From 6cbfb83c65491efab02c327c826932c1cd8487c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:10:12 -0800 Subject: [PATCH 10/10] Select: Same commands take their reference from an object that can be selected Select > Same > Fill Color, Stroke Color, Fill & Stroke, Stroke Weight, Opacity, Blending Mode, Appearance, Shape, Graphic Style and Appearance Attribute compared the other objects with the first selected object, even when select.set had put a hidden or locked object, one on a template layer or one outside the isolated group first. They now take the first selected object that the Select commands could select, in selection order, and keep the compound path and isolation rules for it. A selection that holds no such object returns "nothing selected". --- crates/engine/src/cmd/select.rs | 17 +++++++--- crates/engine/src/tests/menucmds.rs | 52 +++++++++++++++++++++++++++++ crates/engine/src/tests/styles.rs | 26 +++++++++++++++ docs/mcp.md | 6 ++++ docs/target-app-parity.md | 1 + 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 86e9960682..2f04b95c69 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -107,7 +107,7 @@ pub fn specs() -> Vec { "Fill Color", ["Select", "Same"], None, - "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path; from a Live Paint face, the faces of its Live Paint group only, and from any other object, no Live Paint face", + "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path; from a Live Paint face, the faces of its Live Paint group only, and from any other object, no Live Paint face; every select.same command that compares with the first selected object passes over a selected object that is hidden or locked, on a template layer or outside the isolated group", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; @@ -686,12 +686,21 @@ fn paint_reported(d: &Document, n: &Node) -> bool { } /// The object Select > Same compares the others with, and whether isolation mode is inside a -/// compound path. The object is the first selected one, or its compound path when it is a member -/// of one; in isolation mode inside a compound path it is the member path itself. +/// compound path. The object is the first selected one that [`is_selectable`] accepts (none that is +/// hidden, locked, on a hidden, locked or template layer, or outside the isolated group), or its +/// compound path when it is a member of one; in isolation mode inside a compound path it is the +/// member path itself. fn same_reference<'a>(st: &'a DocState, cmd: &str) -> Result<(&'a Node, bool)> { let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. }); let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound))); - let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; + let locks = st.doc.locked_artboards(); + let first = st + .selection + .objects + .iter() + .find(|id| is_selectable(&st.doc, st.isolation, &locks, **id)) + .and_then(|id| st.doc.node(*id)) + .ok_or_else(|| bad(cmd, "nothing selected"))?; let refn = match st.doc.parent_of(first.id).and_then(|p| st.doc.node(p)) { Some(p) if !members && compound(p) => p, _ => first, diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index bdcbc28e2a..823d706532 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -2215,6 +2215,58 @@ fn select_same_matches_compound_paths_on_their_own_paint() { assert_eq!(same(&mut s, outer, "select.same.shapeType"), vec![outer, inner]); } +/// Select > Same compares the other objects with the first selected object that can be selected: +/// one that is hidden or locked, on a template layer, or outside the isolated group is passed +/// over. A selection that holds no such object has nothing to compare with. +#[test] +fn select_same_skips_a_reference_that_cannot_be_selected() { + let mut s = session(); + let [red, blue, other_blue, other_red, grouped_red] = [0.0, 20.0, 40.0, 60.0, 80.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0)); + for id in [red, other_red, grouped_red] { + fill(&mut s, id, "#ff0000"); + } + for id in [blue, other_blue] { + fill(&mut s, id, "#0000ff"); + } + let same = |s: &mut Session, from: &[NodeId], cmd: &str| { + sel(s, from); + assert_eq!(selected(s), from, "select.set keeps {from:?}"); + s.execute(cmd, &json!({})).unwrap(); + let mut v = selected(s); + v.sort(); + v + }; + // A hidden or locked red path selected first is not the reference. + for (on, off) in [ + (json!({"id": red.0, "visible": false}), json!({"id": red.0, "visible": true})), + (json!({"id": red.0, "locked": true}), json!({"id": red.0, "locked": false})), + ] { + s.execute("layer.setProps", &on).unwrap(); + for cmd in ["select.same.fillColor", "select.same.fillAndStroke"] { + assert_eq!(same(&mut s, &[red, blue], cmd), vec![blue, other_blue], "{cmd} {on}"); + } + s.execute("layer.setProps", &off).unwrap(); + } + // A red path on a template layer is not the reference. + let l2 = id_of(&s.execute("layer.new", &json!({"top": true})).unwrap()); + let on_template = rect(&mut s, 100.0, 0.0, 10.0, 10.0); + fill(&mut s, on_template, "#ff0000"); + s.execute("layer.setProps", &json!({"id": l2.0, "template": true})).unwrap(); + assert_eq!(same(&mut s, &[on_template, blue], "select.same.fillColor"), vec![blue, other_blue]); + // A red path outside the isolated group is not the reference. + sel(&mut s, &[blue, grouped_red]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + assert_eq!(same(&mut s, &[other_red, blue], "select.same.fillColor"), vec![blue]); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // With no object that can be selected, there is nothing to compare with, and the selection stays. + s.execute("layer.setProps", &json!({"id": red.0, "visible": false})).unwrap(); + sel(&mut s, &[red, on_template]); + let r = s.execute("select.same.fillColor", &json!({})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == "nothing selected"), "{r:?}"); + assert_eq!(selected(&s), vec![red, on_template]); +} + /// Select > Same > Fill Color and Fill & Stroke from a Live Paint face select the faces of that /// Live Paint group with the same fill, and nothing outside it. From an object outside every Live /// Paint group, they select no face. diff --git a/crates/engine/src/tests/styles.rs b/crates/engine/src/tests/styles.rs index 679186332d..3ffb2ed615 100644 --- a/crates/engine/src/tests/styles.rs +++ b/crates/engine/src/tests/styles.rs @@ -286,6 +286,32 @@ fn select_same_style_and_attribute_from_a_compound_path_member() { assert!(s.execute("select.same.graphicStyle", &json!({})).is_err()); } +/// Select > Same > Graphic Style and Appearance Attribute pass over a hidden object selected first +/// and compare the other objects with the next one. +#[test] +fn select_same_style_and_attribute_skip_a_hidden_reference() { + let mut s = session(); + let a = rect(&mut s, 10.0); + set_fill(&mut s, a, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + run(&mut s, "graphicStyle.new", json!({"name": "Rough"})); + run(&mut s, "graphicStyle.apply", json!({"name": "Rough"})); + let b = rect(&mut s, 100.0); + run(&mut s, "graphicStyle.apply", json!({"name": "Rough"})); + let hidden = rect(&mut s, 200.0); + set_fill(&mut s, hidden, "#0000ff"); + run(&mut s, "graphicStyle.new", json!({"name": "Blue"})); + run(&mut s, "graphicStyle.apply", json!({"name": "Blue"})); + rect(&mut s, 300.0); + run(&mut s, "graphicStyle.apply", json!({"name": "Blue"})); + run(&mut s, "layer.setProps", json!({"id": hidden.0, "visible": false})); + for cmd in ["select.same.graphicStyle", "select.same.appearanceAttribute"] { + run(&mut s, "select.set", json!({"ids": [hidden.0, a.0]})); + run(&mut s, cmd, json!({})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b], "{cmd}"); + } +} + #[test] fn document_info_lists_style_names() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index fb898da5f9..540dc85533 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1459,6 +1459,12 @@ selected objects and the objects inside them, in selection order, as the one to inside a hidden or locked group or layer, on a template layer, or outside the isolated group is passed over. When the selection holds no other type, they return the error "select a text object". +`select.same.fillColor`, `strokeColor`, `fillAndStroke`, `strokeWeight`, `opacity`, `blendingMode`, `appearance`, +`shapeType`, `graphicStyle` and `appearanceAttribute` compare the other objects with the first selected object, in +selection order, that these commands could select. A selected object that is hidden or locked, inside a hidden or +locked group or layer, on a template layer, or outside the isolated group is passed over. When the selection holds no other +object, they return the error "nothing selected". + ## Shaper Groups `shaper.releaseSource {source, group?}` takes the original shape `source` out of its Shaper Group: the shape becomes a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4dd2258510..2de0db9a42 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -924,6 +924,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Select › Same › Link Block Series selects every frame of the threads of the selected type; type in no thread selects itself (`select.same.linkBlockSeries`). The menu entry is listed in [Select objects by characteristics](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects-by-characteristics.html); selecting several threads at once and type in no thread selecting itself are VectorCraft's own behavior. Select › Same › Text Fill & Stroke Color selects the type whose characters match both the fill and the stroke of the selected type (`select.same.textFillAndStrokeColor`). The menu entry is listed in [Select objects by characteristics](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects-by-characteristics.html); requiring one run of characters to match both is VectorCraft's own behavior. The Select › Same font and text color commands take the type to match from the selected type that can be selected: type that is hidden or locked, on a template layer, or outside the isolated group is passed over. This is VectorCraft's own behavior. + Select › Same › Fill Color, Stroke Color, Fill & Stroke, Stroke Weight, Opacity, Blending Mode, Appearance, Shape, Graphic Style and Appearance Attribute compare the other objects with the first selected object that can be selected: an object that is hidden or locked, on a template layer, or outside the isolated group is passed over. This is VectorCraft's own behavior. Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.