From 434c032f9e48c81f4ab99227fcb1c5fa88bd87a1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:26:11 +0700 Subject: [PATCH] Symbols: Direct Selection sets the solid fill and stroke of one object in a dynamic instance Squashed from #5962 onto main. --- crates/doc/src/hit.rs | 251 ++++++ crates/doc/src/hit/tests.rs | 125 +++ crates/doc/src/lib.rs | 2 +- crates/doc/src/selection.rs | 48 ++ crates/doc/src/selection/tests.rs | 76 ++ crates/doc/src/swatches.rs | 27 +- crates/doc/src/symbol_nine_slice.rs | 12 + crates/doc/src/symbol_overrides.rs | 40 + crates/engine/src/cmd.rs | 9 +- crates/engine/src/cmd/appearance.rs | 38 +- crates/engine/src/cmd/brushsym.rs | 12 +- crates/engine/src/cmd/brushsym/dynamic.rs | 67 +- crates/engine/src/cmd/brushsym/symbols.rs | 2 + crates/engine/src/cmd/distortcmds.rs | 11 +- crates/engine/src/cmd/edit.rs | 40 +- crates/engine/src/cmd/fileio/export.rs | 5 +- crates/engine/src/cmd/freeform.rs | 6 +- crates/engine/src/cmd/globaledit.rs | 5 +- crates/engine/src/cmd/gradient.rs | 6 +- crates/engine/src/cmd/graph/edit.rs | 8 +- crates/engine/src/cmd/intertwine.rs | 17 +- crates/engine/src/cmd/live/common.rs | 8 +- crates/engine/src/cmd/maskedit.rs | 21 +- crates/engine/src/cmd/object/transform.rs | 5 + crates/engine/src/cmd/objects_on_path.rs | 6 +- crates/engine/src/cmd/opacitymask.rs | 40 +- crates/engine/src/cmd/paint.rs | 130 ++- crates/engine/src/cmd/path.rs | 6 +- crates/engine/src/cmd/patterncmds.rs | 5 +- crates/engine/src/cmd/place.rs | 8 +- crates/engine/src/cmd/plugin.rs | 10 +- crates/engine/src/cmd/recolor.rs | 7 +- crates/engine/src/cmd/stroke.rs | 25 +- crates/engine/src/cmd/stylelib.rs | 3 + crates/engine/src/cmd/symboledit.rs | 4 + crates/engine/src/command_sets.rs | 5 + crates/engine/src/lib.rs | 4 + crates/engine/src/session.rs | 2 +- crates/engine/src/session/documents.rs | 1 + crates/engine/src/session/edit.rs | 2 +- crates/engine/src/session/execute.rs | 10 +- crates/engine/src/tests/dynamic_symbols.rs | 871 ++++++++++++++++++++- crates/engine/src/tooling.rs | 18 + crates/tools/src/direct.rs | 46 +- crates/tools/src/direct/tests.rs | 162 ++++ crates/tools/src/xform/gradient.rs | 5 + crates/tools/src/xform/gradient/tests.rs | 25 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/gaps.md | 6 +- docs/mcp.md | 30 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 2 +- 78 files changed, 2198 insertions(+), 106 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b3b4394a2..ea3575bf6 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -936,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option } } +/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and +/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance +/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. +struct InstanceArt { + art: Node, + map: Option, + xf: vectorcraft_geom::Affine, +} + +impl InstanceArt { + /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that + /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, + /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. + fn of(doc: &Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); + let map = doc.symbol_slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + return None; + } + Some(Self { art, map, xf: *xf }) + } + + /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the + /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell + /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides + /// object by object. + fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + } + + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by + /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when + /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as + /// [`Document::place_symbol_art`] refuses it. + fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } + + /// Object `n` of the art placed on the canvas without clip groups + /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the + /// same limit ([`Document::place_symbol_art_unclipped`]). + fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } +} + +/// `n` transformed by `xf`, its strokes at their own weight. +fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { + let mut out = n.clone(); + out.transform(xf, false); + out +} + +/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's +/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the +/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the +/// outline of the clipping path of a clip group or a clipping layer picks that path before the art +/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed +/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper +/// group's original shapes, which the canvas does not draw, and guide paths with +/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice +/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not +/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { + struct Find<'a> { + doc: &'a Document, + art: &'a InstanceArt, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + } + impl Find<'_> { + fn hit(&self, n: &Node, whole: Option) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + } + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { + return false; + } + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole); + } + let whole = self.art.whole(n, self.doc, whole); + if n.clips() + && let Some(first) = children.first() + { + let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + if self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && clip_outline(&clip, self.p, self.opt).is_some() + { + path.push(0); + return true; + } + if !clip_contains(&clip, self.p) { + return false; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if self.find(c, path, whole) { + return true; + } + path.pop(); + } + false + } + } + let art = InstanceArt::of(doc, inst)?; + let symbols = doc.symbol_boxes(); + let mut path = vec![]; + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) +} + +/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), +/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a +/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip +/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An +/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut +/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] +/// picks it. +/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. +pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + let art = InstanceArt::of(doc, inst)?; + let part = crate::symbol_overrides::part(&art.art, path)?; + let mut out = BezPath::new(); + let mut limit = None; + let mut whole = None; + let mut n = &art.art; + for &i in path { + let children = n.children()?; + whole = art.whole(n, doc, whole); + if i != 0 + && n.clips() + && let Some(first) = children.first() + && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + { + match meet(Some(clip), limit) { + Some(r) => limit = Some(r), + None => return Some(out), + } + } + n = children.get(usize::try_from(i).ok()?)?; + } + outline_within(&art.place(part, doc, whole)?, limit, &mut out); + Some(out) +} + +/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { + use vectorcraft_geom::kurbo::Shape; + if let Some(pd) = n.path_data() { + match limit { + Some(r) => push_cut(out, &pd.to_bezpath(), r), + None => out.extend(pd.to_bezpath()), + } + } else if n.children().is_none() + && let Some(b) = n.geometric_bounds() + && let Some(b) = meet(Some(b), limit) + { + out.extend(b.to_path(0.1)); + } + let Some(children) = n.children() else { return }; + if n.clips() + && let Some((first, rest)) = children.split_first() + { + let inner = match first.geometric_bounds() { + Some(c) => match meet(Some(c), limit) { + Some(r) => Some(r), + None => return, + }, + None => limit, + }; + rest.iter().for_each(|c| outline_within(c, inner, out)); + return; + } + children.iter().for_each(|c| outline_within(c, limit, out)); +} + +/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. +fn meet(a: Option, b: Option) -> Option { + let a = a?; + let Some(b) = b else { return Some(a) }; + let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1)); + (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r) +} + +/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`. +fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) { + use vectorcraft_geom::kurbo::PathEl; + let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::); + vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| { + let to = match el { + PathEl::MoveTo(q) => { + (at, start, last) = (q, q, None); + return; + } + PathEl::LineTo(q) => q, + PathEl::ClosePath => start, + _ => return, + }; + if let Some((a, b)) = cut_segment(at, to, r) { + if last != Some(a) { + out.move_to(a); + } + out.line_to(b); + last = Some(b); + } + at = to; + }); +} + +/// The part of segment `a` to `b` inside `r`, `None` when none of it is. +fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let d = b - a; + let (mut t0, mut t1) = (0.0_f64, 1.0_f64); + for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] { + if p.abs() < 1e-12 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + (t0 <= t1).then(|| (a + d * t0, a + d * t1)) +} + /// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is /// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an /// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 9a5c40478..495dc227b 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1373,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() { assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); assert_eq!(checked, 201); } + +/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does: +/// Direct Selection picks an object only where the mask shows it, and outlines it there. +#[test] +fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it"); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}"); + // Its outline inside the group around the layer is cut too. + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); +} + +/// In a dynamic symbol's art that placing through its 9-slice guides could make more than +/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the +/// canvas draws nothing of it. At the art's natural size the same art is picked. +#[test] +fn symbol_art_past_the_object_limit_picks_nothing() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + // Drawn in six cells, each with its own copy of the mask's 3,240,000 squares. + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default())); + let scaled = d.alloc_id(); + let natural = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap(); + d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit"); + let opt = HitOptions::default(); + let n = d.node(scaled).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square"); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path"); + assert!(symbol_part_outline(&d, n, &[0]).is_none()); + assert!(symbol_part_outline(&d, n, &[1]).is_none()); + let n = d.node(natural).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0])); + assert!(symbol_part_outline(&d, n, &[0]).is_some()); +} + +/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it +/// included, so an object in it is picked and outlined there even when placing that object through +/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. +#[test] +fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]); + group.set_clips(true); + let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default())); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap(); + let m = d.symbol_slice_map("Corner", xf).unwrap(); + assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit"); + assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn"); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1])); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap()); + assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}"); +} + +/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes, +/// which the canvas does not draw: a click over an original shape whose face the group erased +/// picks nothing, and a click over the face it shows picks the face. +#[test] +fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + let root = Node::group(d.alloc_id(), vec![Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bff8e40f3..8eeb1786d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument}; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; -pub use selection::{AnchorRef, SegmentRef, Selection}; +pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index f03402791..643f81c36 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -54,6 +54,20 @@ pub struct Selection { /// the engine then sets the document's from it. Editing state, never saved. #[serde(skip)] pub new_art: Arc>, + /// An object of a dynamic symbol instance's art, selected with Direct Selection: the instance + /// is the one selected object, and solid fill and stroke changes go to its own appearance for + /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symbol_part: Option, +} + +/// See [`Selection::symbol_part`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolPart { + /// The symbol instance. + pub instance: NodeId, + /// The object in the symbol's art, as [`crate::symbol_overrides::part`] names it. + pub path: Vec, } impl Selection { @@ -79,6 +93,13 @@ impl Selection { self.slices.clear(); self.guides.clear(); self.key_guide = None; + self.symbol_part = None; + } + /// Select only instance `instance`, with the object at `path` of its symbol's art (see + /// [`Selection::symbol_part`]). + pub fn set_symbol_part(&mut self, instance: NodeId, path: Vec) { + self.set([instance]); + self.symbol_part = Some(SymbolPart { instance, path }); } /// Is there a key for Align: an object or a guide? pub fn has_key(&self) -> bool { @@ -93,6 +114,7 @@ impl Selection { pub fn add(&mut self, id: NodeId) { self.target = None; self.slices.clear(); + self.symbol_part = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -100,6 +122,7 @@ impl Selection { pub fn remove(&mut self, id: NodeId) { self.target = None; self.slices.clear(); + self.symbol_part = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -128,6 +151,7 @@ impl Selection { /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: usize) { + self.symbol_part = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -207,6 +231,27 @@ impl Selection { if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { self.key_guide = None; } + // A selected part needs its instance selected alone, with no guides, its symbol dynamic + // and the object still in the symbol's art as a path, compound path or type, where neither + // it nor a group or layer around it is hidden, locked or a template. + let editable_part = |art: &crate::Node, path: &[u32]| { + crate::symbol_overrides::part(art, path).is_some_and(crate::symbol_overrides::takes_part_paint) + && (0..=path.len()) + .all(|k| crate::symbol_overrides::part(art, &path[..k]).is_some_and(|n| n.visible && !n.locked && !n.is_template())) + }; + let part_kept = self.symbol_part.as_ref().is_some_and(|sp| { + self.objects == [sp.instance] + && self.guides.is_empty() + && found.get(&sp.instance).and_then(|p| doc.node_at(p)).is_some_and(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => { + doc.symbols.iter().find(|s| s.name == *symbol).is_some_and(|s| s.dynamic && editable_part(&s.art, &sp.path)) + } + _ => false, + }) + }); + if !part_kept { + self.symbol_part = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay @@ -238,6 +283,9 @@ impl Selection { } self.target = None; self.slices.clear(); + if self.symbol_part.as_ref().is_some_and(|sp| gone.contains(&sp.instance)) { + self.symbol_part = None; + } self.objects.retain(|o| !gone.contains(o)); self.anchors.retain(|id, _| !gone.contains(id)); self.segments.retain(|id, _| !gone.contains(id)); diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index 5665f1140..7882fef73 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -41,6 +41,82 @@ fn guides_are_selected_on_their_own() { assert_eq!(s.guides, vec![0]); } +/// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers +/// around it in the symbol's art are visible, unlocked and not a template. An art edit that hides +/// or locks it, or one around it, or makes a layer around it a template, drops the pick. +#[test] +fn a_picked_symbol_part_is_dropped_once_it_cannot_be_edited() { + let rect = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let leaf = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art()); + let group = Node::group(d.alloc_id(), vec![Arc::new(leaf)]); + let layer = Node::layer(d.alloc_id(), "Inner", crate::LayerColor::Preset(2)); + let root = Node::group(d.alloc_id(), vec![Arc::new(group), Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Parts".into(), art: Arc::new(root.clone()) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Parts".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + // The layer in the art holds a path too. + let in_layer = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art()); + let mut art = root; + if let Some(NodeKind::Layer { children, .. }) = art.children_mut().and_then(|c| c.get_mut(1)).map(|c| &mut Arc::make_mut(c).kind) { + children.push(Arc::new(in_layer)); + } + d.symbols[0].art = Arc::new(art.clone()); + let picked = |d: &Document, path: &[u32]| { + let mut s = Selection::default(); + s.set_symbol_part(inst, path.to_vec()); + s.prune(d); + s.symbol_part.is_some() + }; + assert!(picked(&d, &[0, 0]) && picked(&d, &[1, 0]), "editable parts stay picked"); + assert!(!picked(&d, &[0]) && !picked(&d, &[1]) && !picked(&d, &[]), "a group or layer does not stay picked"); + let edited = |change: &dyn Fn(&mut Node)| { + let mut d = d.clone(); + let mut art = art.clone(); + change(&mut art); + d.symbols[0].art = Arc::new(art); + d + }; + let child = |art: &mut Node, path: &[usize]| -> Node { + let mut n = &*art; + for &i in path { + n = &n.children().unwrap()[i]; + } + n.clone() + }; + let set = |art: &mut Node, path: &[usize], n: Node| { + let mut at = art; + for &i in path { + at = Arc::make_mut(&mut at.children_mut().unwrap()[i]); + } + *at = n; + }; + let hidden = edited(&|art| { + let mut n = child(art, &[0, 0]); + n.visible = false; + set(art, &[0, 0], n); + }); + assert!(!picked(&hidden, &[0, 0]), "the part is hidden"); + let locked_group = edited(&|art| { + let mut n = child(art, &[0]); + n.locked = true; + set(art, &[0], n); + }); + assert!(!picked(&locked_group, &[0, 0]), "the group around it is locked"); + assert!(picked(&locked_group, &[1, 0]), "a part outside the locked group stays"); + let template = edited(&|art| { + let mut n = child(art, &[1]); + if let NodeKind::Layer { template, .. } = &mut n.kind { + *template = true; + } + set(art, &[1], n); + }); + assert!(!picked(&template, &[1, 0]), "the layer around it is a template"); + let hidden_root = edited(&|art| art.visible = false); + assert!(!picked(&hidden_root, &[0, 0]), "the art is hidden"); +} + #[test] fn guides_show_and_lock_with_their_layer() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 5caada836..5aa6147fa 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -176,7 +176,7 @@ impl Document { pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { let mut tree = |n: &Node| { walk_with_masks(n, &mut |m| { - node_paints(m).for_each(&mut *f); + node_paints(m).chain(override_paints(m)).for_each(&mut *f); if let NodeKind::Mesh(mesh) = &m.kind { mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); } @@ -275,10 +275,11 @@ fn map_style_paint(attrs: &mut serde_json::Map, key: } /// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs -/// and gradient stops (with their link), then mesh points (unlinked), in paint order. +/// and gradient stops (with their link), a dynamic symbol instance's own paints +/// ([`override_paints`]), then mesh points (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { n.walk(&mut |m| { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())), @@ -315,6 +316,12 @@ pub fn node_paints(n: &Node) -> impl Iterator { n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref()) } +/// The paints a dynamic symbol instance keeps for objects of its art +/// ([`crate::symbol_overrides`]), which [`node_paints`] leaves out. +pub fn override_paints(n: &Node) -> impl Iterator { + n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten() +} + /// Mutable [`node_paints`], in the same order. pub fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { @@ -353,6 +360,20 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { } out = Some(m); } + // A dynamic symbol instance's own paints for objects of its art (`symbol_overrides`). + if !n.extras().symbol_overrides.is_empty() { + let mut overrides = n.extras().symbol_overrides.clone(); + let mut k = 0; + for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { + if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) { + k += 1; + } + } + if k > 0 { + out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides; + count += k; + } + } if let Some(ch) = n.children() { for (i, c) in ch.iter().enumerate() { if let Some((new, k)) = map_node(c, f) { diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index acc682872..995445f57 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -451,6 +451,18 @@ impl NineSliceMap { self.most_at(art, &boxes, 0) } + /// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one + /// piece, with everything in it: the map of the cell its box lies in, or of the cell at the + /// origin when it has no box. `None` when its box crosses a guide. + pub fn one_cell(&self, n: &Node, doc: &Document) -> Option { + let boxes = doc.symbol_boxes().quick(); + let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + return Some(self.cell_at(Point::ZERO)); + }; + let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); + (cols[0] == cols[1] && rows[0] == rows[1]).then(|| self.cell_xf(cols[0], rows[0])) + } + /// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`. pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 { Self { clips: false, ..*self }.most_placed(art, doc) diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs index b4e3a726f..33f0fd769 100644 --- a/crates/doc/src/symbol_overrides.rs +++ b/crates/doc/src/symbol_overrides.rs @@ -49,6 +49,10 @@ impl SymbolOverride { r.style.fill = p.clone(); } if let Some(p) = &self.stroke { + // A painted stroke on characters without a weight gets 1 pt, as a path's does. + if !p.is_none() && r.style.stroke_width == 0.0 { + r.style.stroke_width = 1.0; + } r.style.stroke = p.clone(); } } @@ -57,6 +61,14 @@ impl SymbolOverride { n.appearance.set_fill(p.clone()); } if let Some(p) = &self.stroke { + // A stroke without paint has no weight; painting it gives it 1 pt. + if !p.is_none() + && let Some(s) = n.appearance.stroke_mut() + && s.paint.is_none() + && s.width == 0.0 + { + s.width = 1.0; + } n.appearance.set_stroke(p.clone()); } } @@ -69,6 +81,13 @@ impl SymbolOverride { } } +/// Can Direct Selection select `n`, an object of a dynamic symbol's art, and change its fill and +/// stroke for one instance? Paths, compound paths and type can; groups, images and every other +/// object cannot. +pub fn takes_part_paint(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) +} + /// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file /// of one endless path stays small once read. fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { @@ -180,6 +199,27 @@ mod tests { assert_eq!(kept.len(), 1); } + #[test] + fn a_stroke_on_type_without_a_weight_gets_one_point() { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let style = crate::CharStyle { stroke: Paint::None, stroke_width: 0.0, ..crate::CharStyle::default() }; + let text = Node::new(NodeId(2), NodeKind::Text(Box::new(crate::TextObject::point(vectorcraft_geom::Point::ZERO, "Hi", style)))); + let mut a = Node::group(NodeId(1), vec![Arc::new(text)]); + apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]); + let Some(NodeKind::Text(t)) = part(&a, &[0]).map(|n| &n.kind) else { panic!("type") }; + assert!(t.runs.iter().all(|r| r.style.stroke == red && r.style.stroke_width == 1.0), "the stroke draws at 1 pt"); + + // A path whose stroke has neither paint nor weight gets 1 pt too. + let mut ap = Appearance::default_art(); + if let Some(s) = ap.stroke_mut() { + (s.paint, s.width) = (Paint::None, 0.0); + } + let path = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap); + let mut a = Node::group(NodeId(1), vec![Arc::new(path)]); + apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]); + assert_eq!(part(&a, &[0]).unwrap().appearance.stroke_width(), 1.0); + } + #[test] fn a_path_deeper_than_the_cap_does_not_read() { let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a26c75da2..c88f47c79 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -590,11 +590,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result Result> { match named_ids(p)? { Some(ids) => Ok(ids), - None => Ok(s.doc()?.selection.objects.clone()), + None => { + edit::refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + } } } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 30e48d4fc..6e369f67b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,7 +126,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, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: 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, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `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); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -1136,6 +1136,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + // An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked + // object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A + // gradient or pattern sampled for it, a source that is not a path, compound path or type (a + // symbol instance's own appearance is empty), and options that pick up neither color are + // refused, and the paint defaults stay as they are. A path of a compound path stands for the + // compound path, which holds the paint. + if !reverse + && !append + && !defaults_only + && p.get("ids").is_none() + && let Some((id, path)) = super::brushsym::selected_part(s)? + { + let d = &s.doc()?.doc; + let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone()); + let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None)); + let a = attrs.appearance; + if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src) + || !(a.fill.color || a.stroke.color) + || (a.fill.color && !super::paint::part_takes(&fill)) + || (a.stroke.color && !super::paint::part_takes(&stroke)) + { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| { + if a.fill.color { + o.fill = Some(fill); + } + if a.stroke.color { + o.stroke = Some(stroke); + } + })?; + // The clicked object becomes the Eyedropper's sample, and new art takes its paint. + Picked::of(&src).to_defaults(s, &attrs.appearance); + s.eyedropper_sample = Some(src); + return Ok(json!({ "ids": [id.0] })); + } let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8cf3a2195..5dcb82136 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -36,7 +36,8 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +pub(crate) use dynamic::{edit_part, part_only, selected_part}; +use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, @@ -266,6 +267,15 @@ pub fn specs() -> Vec { has_selection, symbol_instance_set_appearance ), + cmd!( + "symbol.instance.selectPart", + "Select Symbol Instance Part", + [], + None, + "{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}", + has_doc, + symbol_instance_select_part + ), cmd!( "symbol.instance.reset", "Reset Symbol Instance", diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index baa721c45..6197e57be 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc Result)>> { + Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone()))) +} + +/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo +/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many +/// objects the instance changes. +pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result { + let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone(); + let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() }); + let mut new = old.clone(); + f(&mut new); + if new == old { + return Ok(list.len()); + } + s.edit(label, |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + }) +} + +/// The refusal of anything but a solid color fill or stroke change while an object of a symbol +/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art. +pub(crate) fn part_only() -> EngineError { + EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into()) +} + +pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.selectPart"; + if p.get("id").is_none() { + return Err(bad(C, "missing id")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + // Direct Selection passes over hidden, locked and template objects, and over everything in a + // group or layer that is: the instance in the document, and the object in the symbol's art. + let editable = |n: &Node| n.visible && !n.locked && !n.is_template(); + if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) { + return Err(bad(C, "the object is hidden or locked")); + } + if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) { + return Err(part_only()); + } + let shown = json!(path); + s.select(|_, sel| sel.set_symbol_part(id, path))?; + Ok(json!({ "id": id.0, "path": shown })) +} + /// The `path` param: child indices from the art's root, naming an object of `art`. fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { let path: Option> = @@ -117,16 +173,7 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; - let count = s.edit("Symbol Instance Appearance", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut e = n.extras_mut(); - let list = &mut e.symbol_overrides; - list.retain(|o| o.path != path); - if !new.is_empty() { - list.push(new); - } - Ok(list.len()) - })?; + let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 69bfcb66e..60353921e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; let alt = switch_or(p, "alt", false, C)?; + // Refused while an object of a dynamic instance's art is selected: the instance stays as it is. + crate::cmd::edit::refuse_symbol_part(s)?; let st = s.doc()?; let color = match color_param(p, "color", C)? { Some(c) => crate::cmd::paint::new_color(s, p, c), diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index d54238925..bcaa59426 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; -use super::edit::{duplicate_in, outermost, selected_roots}; +use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, @@ -730,8 +730,13 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { return Err(bad(C, "pins must be finite")); } - let ids = - if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + let selected = || { + refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + }; + let ids = if rest { puppet_ids(p, selected)? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let angles: Vec> = match p.get("angles") { None | Some(Value::Null) => vec![None; pins.len()], diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 21bd4f9bc..8c7de8bdc 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected", has_selection_guides_or_artboard_tool, cut ), @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -220,7 +220,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)", + "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected", has_selection_or_guides, clear ), @@ -829,16 +829,38 @@ fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate:: } /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. +/// +/// Refused with [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art +/// is selected ([`super::brushsym::selected_part`]): only the paint commands change that object, +/// and only its solid fill and stroke, so a command that gathers the selection here leaves the +/// instance as it is. Copy copies the instance whole ([`copy_cut_targets`]). pub(crate) fn selected_roots(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art is selected: +/// for the commands that read the selected objects without [`selected_roots`]. A command that +/// changes nothing in the document ([`Session::reading`]) reads the instance instead. +pub(crate) fn refuse_symbol_part(s: &Session) -> Result<()> { + if !s.reading && s.doc()?.selection.symbol_part.is_some() { Err(super::brushsym::part_only()) } else { Ok(()) } +} + +/// [`refuse_symbol_part`] as a command's `enabled` check: the command is dimmed, and refused with +/// [`super::brushsym::part_only`]'s message, while an object of a dynamic symbol instance's art is +/// selected. +pub(crate) fn no_symbol_part(s: &Session) -> std::result::Result<(), String> { + if !s.reading && s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { Err(super::brushsym::part_only().to_string()) } else { Ok(()) } +} + /// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a /// path of a compound path selected on its own (Group Selection, or in the isolated compound path) /// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the -/// rest still go to the compound path, which holds the paint. +/// rest still go to the compound path, which holds the paint. Refused while an object of a symbol +/// instance's art is selected, as [`selected_roots`] is: it moves with its instance only. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_, _| true)) } @@ -847,8 +869,10 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { /// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the /// objects held by another selected object are dropped. A selected path of a compound path stands /// for its compound unless the compound is `root` or `members` is set (as in -/// [`selected_roots_to_transform`]). +/// [`selected_roots_to_transform`]). Refused while an object of a symbol instance's art is +/// selected, as [`selected_roots`] is. pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root)) @@ -925,6 +949,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } + // With an object of a dynamic symbol instance's art selected, the instance is copied. let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; // Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay @@ -965,6 +990,7 @@ fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } + refuse_symbol_part(s)?; // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { copy(s, p)?; @@ -1038,6 +1064,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { fn clear(s: &mut Session, p: &Value) -> Result { let guides = switch_or(p, "guides", false, "edit.clear")?; let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None }; + // An object of a symbol instance's art can't be removed from the instance alone. + if explicit.is_none() && s.doc()?.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } // Direct-selected anchors or segments: remove those, and any objects selected whole with them. if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) { return super::path::delete_anchors(s, p); diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 13564614b..0869ad850 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -214,10 +214,11 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: } /// The selected objects (their top-most selected ancestors) that are artwork: those on template -/// layers are guides and are left out. +/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, +/// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let ids = edit::selected_roots(s)?; let st = s.doc()?; + let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 21b5245ae..0f72b066a 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -92,8 +92,12 @@ impl Session { /// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in /// document coordinates, and the length its spreads are fractions of there (see - /// [`Node::proxy_freeform`]). + /// [`Node::proxy_freeform`]). `None` while an object of a symbol instance is selected + /// ([`vectorcraft_doc::Selection::symbol_part`]): its points are in the symbol's coordinates. pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> { + if self.active()?.selection.symbol_part.is_some() { + return None; + } let (n, item) = self.proxy_source(stroke)?; n.proxy_freeform(stroke, item) } diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 2b0e214cc..ec0778364 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -354,8 +354,10 @@ fn close(a: f64, b: f64) -> bool { } /// The members a Global Edit started on the one selected object would take, with `o`: that -/// object first, then the others in paint order. +/// object first, then the others in paint order. Refused while an object of a dynamic symbol +/// instance's art is selected. fn matches(s: &Session, o: &Options) -> Result> { + super::edit::refuse_symbol_part(s)?; let st = s.doc()?; let d = &st.doc; let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) }; @@ -423,6 +425,7 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { if st.global_edit.is_some() { return Ok(()); } + super::edit::no_symbol_part(s)?; let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 47989a29c..c73a23d6c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Select Gradient Stop", [], None, - "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}", + "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on; refused while an object of a symbol instance's art is selected → {index}", always, select_stop ), @@ -59,6 +59,10 @@ pub(crate) fn active_gradient(s: &Session) -> Option { fn select_stop(s: &mut Session, p: &Value) -> Result { const C: &str = "gradient.selectStop"; + // No document is needed for the default paint's gradient, so this does not use `refuse_symbol_part`. + if s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { + return Err(super::brushsym::part_only()); + } let index = match p.get("index") { None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")), Some(Value::Null) => None, diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 1ea621c63..c33be78d5 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -58,12 +58,16 @@ pub(super) fn create(s: &mut Session, p: &Value) -> Result { } /// The graph `id` names (or the graph containing it), else the selected graph (or the graph -/// containing the selection). +/// containing the selection). Refused while an object of a dynamic symbol instance's art is +/// selected. pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; let start = match named_id(p)? { Some(id) => vec![id], - None => st.selection.objects.clone(), + None => { + crate::cmd::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } }; for id in start { let mut cur = Some(id); diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index b19206c39..9841d2abb 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -76,8 +76,9 @@ pub fn specs() -> Vec { // ---------- predicates ---------- /// The Intertwine groups of the selection: selected ones, and those around a selected object. +/// None while an object of a dynamic symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine)); @@ -92,6 +93,7 @@ fn selected_groups(s: &Session) -> Vec { fn has_intertwine(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) } } @@ -378,11 +380,14 @@ fn arrange(s: &mut Session, p: &Value) -> Result { } id } - None => match selected_groups(s).as_slice() { - [g] => *g, - [] => return Err(bad(C, "select an Intertwine group")), - _ => return Err(bad(C, "select one Intertwine group")), - }, + None => { + super::edit::refuse_symbol_part(s)?; + match selected_groups(s).as_slice() { + [g] => *g, + [] => return Err(bad(C, "select an Intertwine group")), + _ => return Err(bad(C, "select one Intertwine group")), + } + } }; let at = match p.get("at") { Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?), diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs index 226a57a0d..80c9d714a 100644 --- a/crates/engine/src/cmd/live/common.rs +++ b/crates/engine/src/cmd/live/common.rs @@ -33,9 +33,10 @@ pub(super) fn roots_param(s: &Session, p: &Value) -> Result> { selected_roots(s) } -/// Selected nodes of a kind (the selection itself or an ancestor). +/// Selected nodes of a kind (the selection itself or an ancestor). None while an object of a +/// dynamic symbol instance's art is selected. pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -64,16 +65,19 @@ pub(super) fn is_mesh(n: &Node) -> bool { pub(super) fn has_blend(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } } pub(super) fn has_envelope(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } } pub(super) fn has_mesh(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 01721471c..f2d322a39 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}", - can_edit_mask, + can_enter, enter ), cmd!( @@ -79,6 +79,13 @@ pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> { mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into())) } +/// Edit Opacity Mask: mask editing can be entered ([`can_edit_mask`]), and outside it no object of a +/// dynamic symbol instance's art is selected, since its instance's mask is not edited from it. +fn can_enter(s: &Session) -> std::result::Result<(), String> { + can_edit_mask(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode /// are not entered while an opacity mask is edited. pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> { @@ -117,12 +124,17 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let given = object_param(p, "id", C)?; + if given.is_none() { + super::edit::refuse_symbol_part(s)?; + } + let id = given.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = start(s, id)?; Ok(json!({ "layer": layer.0 })) } -/// The first selected object with an opacity mask. +/// The first selected object with an opacity mask. Callers refuse first while an object of a +/// dynamic symbol instance's art is selected ([`super::edit::refuse_symbol_part`]). fn first_masked(st: &crate::DocState) -> Option { st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } @@ -145,6 +157,9 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { st.revision += 1; return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); } + if given.is_none() && editing.is_none() { + super::edit::refuse_symbol_part(s)?; + } let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; match editing { Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index b400f637a..061f4fb9d 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -242,6 +242,11 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; check_switches(p, &["anchors", "releaseOutside"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; + // An object selected in a symbol instance's art stays where the symbol puts it: the selection + // is left as it is, and the reply's warning goes to the status bar. + if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { + return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); + } let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index e2bd0957d..93e2bd929 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -82,9 +82,10 @@ fn groups_of(d: &Document, ids: &[NodeId]) -> Vec { out } -/// The Objects on Path groups of the selection ([`groups_of`]). +/// The Objects on Path groups of the selection ([`groups_of`]). None while an object of a dynamic +/// symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() + s.active().filter(|st| st.selection.symbol_part.is_none()).map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() } /// The groups a command acts on: those of `ids` when given, otherwise those of the selection. @@ -102,6 +103,7 @@ fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result> { fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 7089770e4..b8b0138c5 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)", - has_doc, + can_change_mask, release ), cmd!( @@ -43,25 +43,22 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} keep the mask but stop applying it", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true })) ), - cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( - s, - p, - "Enable Opacity Mask", - &json!({ "disabled": false }) - )), + cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| { + set_flags(s, p, "Enable Opacity Mask", &json!({ "disabled": false })) + }), cmd!( "transparency.unlinkOpacityMask", "Unlink Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?} the object moves without its mask", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false })) ), - cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( + cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| set_flags( s, p, "Link Opacity Mask", @@ -73,7 +70,7 @@ pub fn specs() -> Vec { [], None, "{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Opacity Mask Options", p) ), cmd!( @@ -152,9 +149,21 @@ pub(crate) fn percent(o: f64) -> f32 { (o / 100.0).clamp(0.0, 1.0) as f32 } -/// The [`transparency_targets`] that have an opacity mask. +/// Refuses a command that would change [`transparency_targets`] when they are the selection, without +/// `id` or `ids` and outside mask editing, while an object of a dynamic symbol instance's art is +/// selected: the instance's transparency and mask change only when the instance is selected. +pub(crate) fn refuse_symbol_part_unnamed(s: &Session, p: &Value) -> Result<()> { + if p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_none() { + super::edit::refuse_symbol_part(s)?; + } + Ok(()) +} + +/// The [`transparency_targets`] that have an opacity mask, refused as +/// [`refuse_symbol_part_unnamed`] says. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; + refuse_symbol_part_unnamed(s, p)?; let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); @@ -162,6 +171,13 @@ fn masked_targets(s: &Session, p: &Value) -> Result> { Ok(ids) } +/// The commands that change an existing opacity mask: a document is open, and outside mask editing +/// no object of a dynamic symbol instance's art is selected ([`masked_targets`]). +fn can_change_mask(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty /// mask and takes into mask editing, is where mask editing can be entered /// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index bbb5f35d3..53a592492 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,5 +1,7 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. +use std::borrow::Cow; + use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; @@ -19,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -28,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -86,7 +88,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -95,7 +97,7 @@ pub fn proxy_specs() -> Vec { "Complement", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model()) ), @@ -172,7 +174,7 @@ impl Session { /// new art. pub fn proxy_paints(&self) -> (Paint, Paint) { let show = |stroke: bool| match self.proxy_source(stroke) { - Some((n, item)) => proxy_paint(n, stroke, item), + Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), }; @@ -180,17 +182,22 @@ impl Session { } /// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item - /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. - pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option)> { + /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. With + /// an object of a dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that + /// object as the instance draws it. + pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(Cow<'_, Node>, Option)> { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return Some((Cow::Owned(part), None)); + } let first = st.doc.node(*st.selection.subjects().first()?)?; let item = self.appearance_item(); if first.appearance.item_of_kind(item, !stroke).is_some() { - return Some((first, item)); + return Some((Cow::Borrowed(first), item)); } let mut nodes = vec![]; painted(first, true, &mut nodes); - nodes.first().map(|n| (*n, None)) + nodes.first().map(|n| (Cow::Borrowed(*n), None)) } pub(crate) fn remember_paint_now(&mut self, p: &Paint) { @@ -257,6 +264,9 @@ impl DocState { if self.selection.is_empty() { return (false, false); } + if self.selection.symbol_part.is_some() { + return (false, false); + } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); let (mut selected, mut nodes) = (vec![], vec![]); self.doc.walk(|n| { @@ -275,6 +285,18 @@ impl DocState { }; (differ(false), differ(true)) } + + /// The object of a dynamic symbol instance's art selected with Direct Selection + /// ([`vectorcraft_doc::Selection::symbol_part`]), with the instance's own appearance changes + /// applied, in the symbol's coordinates. `None` without one. + pub(crate) fn selected_symbol_part(&self) -> Option { + let sp = self.selection.symbol_part.as_ref()?; + let inst = self.doc.node(sp.instance)?; + let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { return None }; + let sym = self.doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst); + vectorcraft_doc::symbol_overrides::part(&art, &sp.path).cloned() + } } impl Session { @@ -465,6 +487,26 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) s.remember_paint(&paint); return Ok(json!({"objects": ids.len()})); } + // An object of a dynamic symbol instance's art selected with Direct Selection: a solid color or + // none changes that object for this instance only, and anything else is refused. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + if !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, if fill { "Fill Color" } else { "Stroke Color" }, |o| { + if fill { + o.fill = Some(paint.clone()); + } else { + o.stroke = Some(paint.clone()); + } + })?; + paint_new_art(s, &paint, fill); + if focus { + s.fill_active = fill; + } + s.remember_paint(&paint); + return Ok(json!({"objects": 1})); + } // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; @@ -553,15 +595,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }, )?; // Rejected edits leave the next shape's appearance and active proxy untouched. - // New art takes an unplaced gradient so that it fits the new object's own bounds. - if fill { - s.paint.fill = unplaced(&paint); - } else { - if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { - s.paint.stroke_width = 1.0; - } - s.paint.stroke = unplaced(&paint); - } + paint_new_art(s, &paint, fill); if focus { s.fill_active = fill; } @@ -569,6 +603,39 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) Ok(json!({"objects": ids.iter().collect::>().len()})) } +/// The instance whose object is selected with Direct Selection, and that object's path +/// ([`super::brushsym::selected_part`]), when `p` names no `ids`, `id` or `item`: the paint commands +/// then change that object. A bad `target` or `aligned` is refused as it is for any other selection. +fn selected_part_target(s: &Session, p: &Value, cmd: &str) -> Result)>> { + if p.get("ids").is_some() || p.get("id").is_some() || p.get("item").is_some() { + return Ok(None); + } + let part = super::brushsym::selected_part(s)?; + if part.is_some() { + item_target(s, p, cmd)?; + } + Ok(part) +} + +/// Can an object of a dynamic symbol instance's art selected with Direct Selection take `paint`? A +/// solid color or none can; a gradient or pattern cannot. +pub(crate) fn part_takes(paint: &Paint) -> bool { + matches!(paint, Paint::Solid { .. } | Paint::None) +} + +/// Make `paint` the fill or stroke of new art. New art takes an unplaced gradient so that it fits +/// the new object's own bounds, and a painted stroke on art that had none gets the 1 pt default. +fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { + if fill { + s.paint.fill = unplaced(paint); + } else { + if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { + s.paint.stroke_width = 1.0; + } + s.paint.stroke = unplaced(paint); + } +} + /// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. @@ -824,6 +891,32 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { /// active item of the proxy's kind is recoloured in each selected object's own stack instead. fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p, cmd)?; + // An object of a dynamic symbol instance's art selected with Direct Selection: the solid color + // it shows is recolored for this instance only. A gradient or pattern is refused, and so is type + // whose characters differ in that paint, since one change holds one paint. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + let part = s.doc()?.selected_symbol_part(); + let mut paint = part.as_ref().map_or(Paint::None, |n| proxy_paint(n, stroke, None)); + let runs_differ = match part.as_ref().map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.runs.iter().any(|r| !same_in_proxy(if stroke { &r.style.stroke } else { &r.style.fill }, &paint)), + _ => false, + }; + if runs_differ || !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + let changed = super::colorcmds::map_paint(&mut paint, f); + if changed { + super::brushsym::edit_part(s, id, &path, label, |o| { + if stroke { + o.stroke = Some(paint.clone()); + } else { + o.fill = Some(paint.clone()); + } + })?; + s.remember_paint(&paint); + } + return Ok(json!({ "changed": changed as usize })); + } let ids = match super::given_objects(s, p, cmd)? { Some(ids) => ids, None => super::edit::selected_roots(s)?, @@ -863,6 +956,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( fn transparency(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.set"; + super::opacitymask::refuse_symbol_part_unnamed(s, p)?; let ids = super::opacitymask::transparency_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e0de6bd4a..4aeeed95c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Move Anchors", [], None, - "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)", + "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected); refused while an object of a symbol instance's art is selected", has_selection, move_anchors ), @@ -226,6 +226,8 @@ fn close(s: &mut Session, p: &Value) -> Result { pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?); check_switches(p, &["patterns"], "path.moveAnchors")?; + // An object selected in a symbol instance's art stays where the symbol puts it. + super::edit::refuse_symbol_part(s)?; // Objects selected whole move as objects, and their pattern tiles move with them when // `patterns` (default: General › Transform Pattern Tiles) is on. let whole = s.doc()?.selection.whole(); @@ -341,7 +343,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { } /// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. -pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { +pub(crate) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 614a37d21..1e7f88d2b 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -189,8 +189,10 @@ fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> { has_doc(s) } +/// The repeats of the selection: selected ones, and those around a selected object. None while +/// an object of a dynamic symbol instance's art is selected. fn selected_repeats(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -207,6 +209,7 @@ fn selected_repeats(s: &Session) -> Vec { fn has_repeat(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 0de33a22c..930559db3 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -534,7 +534,7 @@ impl Session { if st.doc.node(old) != read.job.replaced.as_ref() { return Err(bad(PLACE, "replace: the object to replace was changed or deleted")); } - if st.selection.objects != [old] { + if st.selection.objects != [old] || st.selection.symbol_part.is_some() { self.execute("select.set", &json!({ "ids": [old.0] }))?; } } @@ -565,6 +565,10 @@ fn prepare(s: &Session, p: &Value) -> Result { if corners.iter().flatten().any(|c| !(c.x.abs() <= MAX_COORD && c.y.abs() <= MAX_COORD)) { return Err(bad(PLACE, "at/rect lie outside the canvas")); } + // An object selected in a symbol instance's art can't be swapped, and its instance stays. + if replace { + super::edit::refuse_symbol_part(s)?; + } let st = s.doc()?; let old = match (replace, &st.selection.objects[..]) { (false, _) => None, diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..dd091fb71 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -139,7 +139,15 @@ impl FilterRun { let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; - let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, + // as `selected_roots` refuses it. + let selected = match ids_param(p, "ids") { + Some(ids) => ids, + None => { + super::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } + }; // Without `doc.read` a filter is given no objects: it can only add new ones. let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index f4996f4ad..00b87c649 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; use vectorcraft_color::{Color, Paint, Swatch}; -use vectorcraft_doc::swatches::{node_colors, node_paints}; +use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; @@ -99,7 +99,7 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> read(n, only, &mut nodes); } for m in nodes { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())), @@ -522,6 +522,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { }; let row_swatches: Vec> = rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + super::edit::refuse_symbol_part(s)?; let ids = s.doc()?.selection.objects.clone(); let group = text_param(p, "group", C)?; if ids.is_empty() && group.is_none() { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 4937ff200..c9df27002 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -343,10 +343,15 @@ impl Session { /// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the /// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else /// the first selected object's (a group's first painted object's) top stroke, type's first - /// run's character stroke; with nothing selected, the next object drawn's - /// ([`Session::new_art_stroke`]). None without a document or the selection's stroke. + /// run's character stroke; with an object of a dynamic symbol instance selected + /// ([`DocState::selected_symbol_part`]), that object's as the instance draws it; with nothing + /// selected, the next object drawn's ([`Session::new_art_stroke`]). None without a document or + /// the selection's stroke. pub fn shown_stroke(&self) -> Option { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return node_stroke(&part); + } let Some(first) = st.selection.objects.first() else { return Some(self.new_art_stroke()) }; let first = st.doc.node(*first)?; if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) { @@ -360,13 +365,19 @@ impl Session { impl DocState { /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, - /// and whether Align Stroke applies to them (no open path among them). One walk of the - /// selection, so callers that ask every frame cache it by revision. + /// and whether Align Stroke applies to them (no open path among them). With an object of a + /// dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that object alone is + /// read. One walk of the selection, so callers that ask every frame cache it by revision. pub fn stroke_mixed(&self) -> StrokeMixed { + let part = self.selected_symbol_part(); let mut nodes = vec![]; - for id in &self.selection.objects { - if let Some(n) = self.doc.node(*id) { - painted(n, true, &mut nodes); + if let Some(part) = &part { + painted(part, true, &mut nodes); + } else { + for id in &self.selection.objects { + if let Some(n) = self.doc.node(*id) { + painted(n, true, &mut nodes); + } } } let mut strokes = vec![]; diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 2864f574b..7b0cad576 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -156,6 +156,9 @@ fn add(s: &mut Session, p: &Value) -> Result { let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?); if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)?; + } else if apply { + // A selection that can't take a style is refused before any style is added. + super::appearance::appearance_targets(s, &json!({}))?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 618ef2ee3..de262cf2b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -78,6 +78,10 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { (name, xf, ids.first().copied()) } None => { + // Refused while an object of a dynamic instance's art is selected, unless `name` is given. + if str_param(p, "name").is_none() && st.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) }; let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect(); match symbols.first() { diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index a115498e1..2224ddccc 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -73,6 +73,11 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { } } +/// Journaled commands that write a file and change nothing in the document: with an object of a +/// dynamic symbol instance's art selected, they read the instance as queries do +/// ([`crate::Session::execute`]). +pub(crate) const JOURNALED_READS: &[&str] = &["css.export"]; + /// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with /// its checks), then running as on the selection: the result and the selection afterwards are the /// menu command's (#2130). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 399a4ed11..79aa06891 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -164,6 +164,10 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// While a command that changes nothing in the document runs (a query, or one of + /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol + /// instance's art. + pub(crate) reading: bool, /// While [`Session::place_read`] runs `file.place`: the file read for it, which the command /// places instead of reading the file; `None` otherwise. pub(crate) place_hand_off: Option, diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1eaf15b88..27976ff51 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,4 +1,4 @@ -use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; +use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; use super::doc_state::DocState; use super::error::{EngineError, Result}; use super::history::NamedStep; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ec9fdbac1..d9ef5740b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -81,6 +81,7 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + reading: false, place_hand_off: None, queue_hand_off: None, place_queue_roots: None, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index da285eb3d..e3fae1030 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -340,7 +340,7 @@ impl Session { preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0), ); st.keep_interaction(it); - if preview.0 == "object.transform" { + if preview.0 == "object.transform" && changed { let m = cmd::matrix_param(&preview.1, "matrix"); if let Some(m) = m { // The options are the last preview's params with the values it noted, without the diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 21822583d..71f90e7e4 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,5 +1,6 @@ use super::{ - CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only, + CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, + strip_replay_only, }; impl Session { @@ -35,6 +36,13 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id)); + let r = self.execute_spec(spec, id, params); + self.reading = outer; + r + } + + fn execute_spec(&mut self, spec: &'static CommandSpec, id: &str, params: &Value) -> Result { // A key the command's params description doesn't name is a parameter error (#1442), before // anything runs, for the commands other commands run too. A `command.batch` or an // `action.batch` checks its steps before the first one runs, and each step is checked again diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 1c46d5302..9ec00cd24 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -1,10 +1,14 @@ //! Dynamic symbols (#4938): an instance changes the appearance of objects in its symbol's art and -//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. +//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. Direct +//! Selection picks one path, compound path or type object of an instance's art, and a solid color +//! Fill or Stroke changes it. use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use crate::tooling::{UiRequest, ViewInfo}; use crate::*; fn session() -> Session { @@ -46,6 +50,9 @@ fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; +/// The refusal of anything but a solid fill or stroke on a picked object says so. +const REFUSED: &str = "solid color fill and stroke"; + #[test] fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { let mut s = session(); @@ -58,6 +65,10 @@ fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { assert_eq!(pixel(&s, 80, 30), RED, "the changed object"); assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); assert_eq!(overrides(&s, other), 0, "other instances keep the symbol's look"); + // The same value again records nothing. + let undo = undo_steps(&s); + assert_eq!(s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap()["overrides"], 1); + assert_eq!(undo_steps(&s), undo, "no undo step"); // A later change to the same object merges with the first; null takes one back. s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": 50})).unwrap(); assert_eq!(node(&s, inst).extras().symbol_overrides[0].opacity, Some(0.5)); @@ -176,6 +187,666 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() { assert_eq!(overrides(&s, other), 1, "the instance keeps its changes"); } +/// Press the Direct Selection tool at (`x`, `y`), drag to `to` and release, with `m` held: what the +/// drag asked of the UI. +fn direct(s: &mut Session, (x, y): (f64, f64), to: (f64, f64), m: Mods) -> Vec { + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(m), v).unwrap(); + let mut out = vec![]; + if to != (x, y) { + out = s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap(); + out +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The fills of the symbol's own art. +fn symbol_fills(s: &Session) -> Vec { + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + art.children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect() +} + +#[test] +fn direct_selection_paints_one_object_of_one_dynamic_instance() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [inst]); + assert_eq!(sel.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] })); + let undo = undo_steps(&s); + // The Swatches panel, the Color panel and the Control bar set the fill with paint.setFill. + assert_eq!(s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap()["objects"], 1); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(pixel(&s, 80, 30), RED, "the picked object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"], "the symbol's art is unchanged"); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#ff0000".into()), "new art takes the fill"); + // The same paint again records nothing. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(undo_steps(&s), undo + 1); + // Stroke goes to the same object. + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(s.paint.stroke.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the stroke"); + assert!(s.paint.stroke_width > 0.0, "a painted stroke has a weight"); + let o = node(&s, inst).extras().symbol_overrides[0].clone(); + assert_eq!(o.path, vec![1]); + assert!(o.fill.is_some() && o.stroke.is_some()); + // The Eyedropper's click on another object gives the selected object its paint. + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap()["ids"], json!([inst.0])); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255], "the eyedropper's paint"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"]); + for _ in 0..7 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(overrides(&s, inst), 0, "undo takes each change back"); + assert_eq!(pixel(&s, 80, 30), BLUE); +} + +#[test] +fn a_drag_of_a_picked_object_moves_nothing_and_says_why() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let (before, undo) = (node(&s, inst).kind.clone(), undo_steps(&s)); + let ui = direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("symbol instance"))), "{ui:?}"); + assert_eq!(node(&s, inst).kind, before, "the instance stays put"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert!(s.execute("object.nudge", &json!({"dx": 1})).is_err(), "the arrow keys too"); + assert!(s.execute("object.rotate", &json!({"angle": 10})).is_err()); + // The Selection tool picks the instance whole again, and it moves. + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before); +} + +/// The Stroke panel and the Control bar show the stroke of the object picked in a dynamic instance. +#[test] +fn the_stroke_panel_shows_a_picked_objects_stroke() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2})).unwrap(); + s.execute("stroke.set", &json!({"ids": [b.0], "weight": 7})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(7.0), "the picked object's weight"); + assert!(!s.doc().unwrap().stroke_mixed().weight, "the instance's art has two weights, the object one"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(2.0)); +} + +#[test] +fn delete_and_cut_of_a_picked_object_remove_nothing() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["edit.cut", "edit.clear"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{cmd}: {e}"); + assert!(s.doc().unwrap().doc.node(inst).is_some(), "{cmd} keeps the instance"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); + // Named by id, the instance is removed. + s.execute("edit.clear", &json!({"ids": [inst.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); +} + +#[test] +fn commands_on_the_selection_leave_a_picked_objects_instance_alone() { + // A dynamic instance with an opacity mask, a plain square and the Desaturate plug-in, with an + // object of the instance picked. + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let desaturate = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); + let plugin = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(desaturate)})).unwrap(); + let mask = square(&mut s, 20.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [inst.0, mask.0]})).unwrap(); + assert!(node(&s, inst).extras().mask.is_some()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst, other, plugin["id"].clone()) + }; + let (mut s, inst, other, plugin) = setup(); + let doc = s.doc().unwrap().doc.clone(); + // Each command runs on its own copy of the setup, compared with that copy as it was, so every + // command that fails is reported. + let mut failures = Vec::new(); + for (cmd, p) in [ + ("object.puppetWarp", json!({"pins": [[80, 30]], "moved": [[90, 40]], "rest": true})), + ("recolor.apply", json!({"map": {"#0000ff": "#00ff00"}})), + ("plugin.run", json!({"id": plugin, "params": {"amount": 100}})), + ("object.group", json!({})), + ("object.ungroup", json!({})), + ("object.hide", json!({})), + ("object.lock", json!({})), + ("object.arrange.bringToFront", json!({})), + ("object.expand", json!({})), + ("object.rotate", json!({"angle": 45})), + ("symbol.instance.reset", json!({})), + ("symbol.instance.setAppearance", json!({"path": [1], "opacity": 50})), + ("symbol.adjust", json!({"tool": "shift", "points": [[80, 30], [95, 30]], "radius": 200})), + ("symbol.edit", json!({})), + ("select.globalEdit", json!({})), + ("object.blend.release", json!({})), + ("object.repeat.release", json!({})), + ("object.intertwine.release", json!({})), + ("object.objectsOnPath.expand", json!({})), + ("transparency.set", json!({"opacity": 50})), + ("transparency.set", json!({"blend": "multiply"})), + ("transparency.releaseOpacityMask", json!({})), + ("transparency.setOpacityMask", json!({"invert": true, "clip": false})), + ("transparency.disableOpacityMask", json!({})), + ("transparency.unlinkOpacityMask", json!({})), + ("transparency.editOpacityMask", json!({})), + ("transparency.viewOpacityMask", json!({"on": true})), + ("paint.editGradient", json!({"angle": 30})), + ("paint.setGradientGeom", json!({"start": [62, 30], "end": [98, 30]})), + ("paint.swap", json!({})), + ("stroke.set", json!({"weight": 3})), + ("path.moveAnchors", json!({"dx": 5, "dy": 5})), + ( + "file.place", + json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(b""), "replace": true}), + ), + ("object.intertwine.arrange", json!({"at": [80, 30]})), + ("transparency.editOpacityMask", json!({"id": null})), + ("transparency.viewOpacityMask", json!({"on": true, "id": null})), + ] { + let (mut t, ..) = setup(); + let (doc, undo) = (t.doc().unwrap().doc.clone(), undo_steps(&t)); + match t.execute(cmd, &p) { + Ok(r) => failures.push(format!("{cmd}: ran ({r})")), + Err(e) if !e.to_string().contains("symbol instance") => failures.push(format!("{cmd}: {e}")), + Err(_) => {} + } + if t.doc().unwrap().selection.symbol_part.is_none() { + failures.push(format!("{cmd}: the object is no longer picked")); + } + if t.doc().unwrap().doc != doc { + failures.push(format!("{cmd}: the document changed")); + } + if undo_steps(&t) != undo { + failures.push(format!("{cmd}: an undo step was added")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + // Copy copies the instance whole. + s.execute("edit.copy", &json!({})).unwrap(); + assert!(matches!(&s.clipboard.nodes[..], [n] if matches!(n.kind, NodeKind::SymbolInstance { .. }))); + // The picked object's paint still changes. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_some()); + // A command given ids acts on them. + s.execute("object.rotate", &json!({"ids": [other.0], "angle": 45})).unwrap(); + assert_ne!(node(&s, other).kind, doc.node(other).unwrap().kind, "the named object turns"); + s.execute("transparency.setOpacityMask", &json!({"id": inst.0, "invert": true})).unwrap(); + assert!(node(&s, inst).extras().mask.as_ref().is_some_and(|m| m.invert), "the named instance's mask changes"); +} + +#[test] +fn the_commands_without_a_check_for_a_picked_object_use_its_instance() { + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst) + }; + // Export Selection: the whole instance, x 10..100, and not the object at 60..100. + let (mut s, _) = setup(); + let r = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap(); + assert_eq!(r["bounds"], json!([10.0, 10.0, 90.0, 40.0])); + // Fit to Selection: the whole instance too. + let (mut s, _) = setup(); + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((r.x0, r.x1), (10.0, 100.0)); + // Liquify: the instance is its target, skipped and left as it is. + let (mut s, inst) = setup(); + let doc = s.doc().unwrap().doc.clone(); + let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[80, 30]], "diameter": 150, "intensity": 1})).unwrap(); + assert_eq!((&r["ids"], &r["skipped"]), (&json!([]), &json!([inst.0]))); + assert!(s.doc().unwrap().doc == doc); + // Isolate: the instance, with the selection cleared. + let (mut s, inst) = setup(); + s.execute("object.isolate", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(inst)); + assert!(s.doc().unwrap().selection.is_empty()); + // Add to Library: a copy of the whole instance, 90 pt wide, and not the object's paint. + let (mut s, _) = setup(); + let lib = s.execute("library.add", &json!({"kind": "graphic"})).unwrap()["library"].clone(); + let got = s.execute("library.get", &json!({"library": lib})).unwrap(); + assert_eq!(got["graphics"][0]["width"], 90.0); + assert_ne!(s.execute("library.add", &json!({"kind": "fillColor"})).unwrap()["name"], "#ff0000"); + // Save Selection: the instance, which Recall selects whole. + let (mut s, inst) = setup(); + s.execute("select.save", &json!({"name": "Saved"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.saved_selections[0].objects, vec![inst]); + s.execute("select.recall", &json!({"name": "Saved"})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((&sel.objects, &sel.symbol_part), (&vec![inst], &None)); +} + +#[test] +fn a_blend_around_a_picked_objects_instance_is_left_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [inst.0, other.0], "steps": 1})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let (doc, undo) = (s.doc().unwrap().doc.clone(), undo_steps(&s)); + let e = s.execute("object.blend.release", &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{e}"); + assert!(s.doc().unwrap().doc == doc, "the blend stays"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + // With the blend selected, it is released. + s.execute("select.set", &json!({"ids": [blend.0]})).unwrap(); + s.execute("object.blend.release", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(blend).is_none()); +} + +/// Dynamic symbol "Photo" of a gray 2 × 1 image and a blue square, made in place: its instance and +/// the image's path in the art. +fn image_symbol(s: &mut Session) -> (NodeId, Value) { + let mut png = Vec::new(); + let pixels = image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()); + pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, _| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf: vectorcraft_geom::Affine::translate((10.0, 10.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + Ok(id) + }) + .unwrap(); + let sq = square(s, 60.0); + let inst = id(&s.execute("symbol.new", &json!({"name": "Photo", "ids": [im.0, sq.0], "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + let i = art.children().unwrap().iter().position(|c| matches!(c.kind, NodeKind::Image(_))).unwrap(); + (inst, json!([i])) +} + +#[test] +fn only_paths_compound_paths_and_type_of_the_art_are_picked() { + let mut s = session(); + // A compound path, type, a blend and a static symbol's instance beside a path, in a group. + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + let text = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + let (c, d) = (square(&mut s, 60.0), square(&mut s, 150.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [c.0, d.0], "steps": 1})).unwrap()); + let inner = pair(&mut s, "static"); + let plain = square(&mut s, 100.0); + let group = id(&s.execute("object.group", &json!({"ids": [plain.0]})).unwrap()); + let ids = [compound.0, text.0, blend.0, inner.0, group.0]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Mixed", "ids": ids, "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols.iter().find(|y| y.name == "Mixed").unwrap().art.clone(); + let at = |kind: &dyn Fn(&NodeKind) -> bool| art.children().unwrap().iter().position(|c| kind(&c.kind)).unwrap(); + let compound = at(&|k| matches!(k, NodeKind::Compound { .. })); + let text = at(&|k| matches!(k, NodeKind::Text(_))); + let blend = at(&|k| matches!(k, NodeKind::Blend { .. })); + let inner = at(&|k| matches!(k, NodeKind::SymbolInstance { .. })); + let group = at(&|k| matches!(k, NodeKind::Group { .. })); + for path in [json!([compound]), json!([text]), json!([group, 0])] { + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + } + assert_eq!(overrides(&s, inst), 3); + let undo = undo_steps(&s); + s.execute("select.none", &json!({})).unwrap(); + for path in [json!([]), json!([group]), json!([blend]), json!([inner])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + assert_eq!((overrides(&s, inst), undo_steps(&s)), (3, undo), "nothing changes"); +} + +/// A Direct Selection press on an image of the art is refused with the status message, and a drag +/// from it does not move what was selected. +#[test] +fn a_press_on_an_image_of_the_art_is_refused_and_moves_nothing() { + let mut s = session(); + let (inst, path) = image_symbol(&mut s); + let other = square(&mut s, 150.0); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let before = node(&s, other).kind.clone(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 60.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 60.0), v).unwrap(); + assert_eq!(node(&s, other).kind, before, "what was selected stays put"); + assert_eq!(s.doc().unwrap().selection.objects, [other], "and stays selected"); + assert_eq!(overrides(&s, inst), 0); +} + +/// A gradient or pattern for a picked object is refused and changes nothing, new art's paint +/// included. An object that shows a gradient takes a solid color. +#[test] +fn gradients_and_patterns_on_a_picked_object_are_refused() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("swatch.new", &json!({"name": "Ramp", "gradient": g})).unwrap(); + let tile = square(&mut s, 150.0); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + // An object to sample a gradient from. + let far = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [far.0], "gradient": g})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone()); + for (cmd, p) in [ + ("paint.setFill", json!({"gradient": g})), + ("paint.setStroke", json!({"gradient": g})), + ("paint.setFill", json!({"gradient": g, "span": "selection"})), + ("paint.setFill", json!({"swatch": "Ramp"})), + ("paint.setFill", json!({"swatch": "Dots"})), + ("appearance.copyFrom", json!({"source": far.0})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd} {p}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "new art keeps its paint"); + // An object whose art has a gradient takes a solid color; Invert, Complement and selecting one + // of its stops are refused. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": {"gradient": g}})).unwrap(); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + s.fill_active = true; + for index in [json!(1), Value::Null] { + let e = s.execute("gradient.selectStop", &json!({"index": index})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "gradient.selectStop {index}: {e}"); + } + assert_eq!(s.selected_stop(), None); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // The Eyedropper's options can leave the gradient out: the fill stays. + s.execute("appearance.copyFrom", &json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}}}})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // Options that pick up neither color leave nothing to change, so the click is refused. + let (undo, fill) = (undo_steps(&s), s.paint.fill.clone()); + let neither = json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}, "stroke": {"color": false}}}}); + let e = s.execute("appearance.copyFrom", &neither).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); +} + +#[test] +fn select_part_refuses_hidden_and_locked_objects_of_the_art() { + let mut s = session(); + let (a, b, c) = (square(&mut s, 10.0), square(&mut s, 60.0), square(&mut s, 110.0)); + let group = id(&s.execute("object.group", &json!({"ids": [b.0, c.0]})).unwrap()); + let inst = id(&s.execute("symbol.new", &json!({"name": "Three", "ids": [a.0, group.0], "type": "dynamic"})).unwrap()); + // The art's first square is hidden and its group locked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let children = art.children_mut().unwrap(); + std::sync::Arc::make_mut(&mut children[0]).visible = false; + std::sync::Arc::make_mut(&mut children[1]).locked = true; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + for path in [json!([0]), json!([1]), json!([1, 0])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + // With the art's group unlocked, a locked instance's objects are still not picked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + std::sync::Arc::make_mut(&mut art.children_mut().unwrap()[1]).locked = false; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("object.lock", &json!({"ids": [inst.0]})).unwrap(); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "locked instance: {e}"); + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap(); +} + +#[test] +fn choosing_the_selection_tool_again_picks_the_instance_whole() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + let revision = s.doc().unwrap().revision; + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(s.doc().unwrap().revision, revision, "panels cached by revision read the instance"); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); +} + +#[test] +fn the_eyedropper_sample_taken_for_a_picked_object_is_kept() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + let target = square(&mut s, 150.0); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap(); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the sample's fill"); + // A reverse Eyedropper click with nothing selected applies that sample. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": target.0, "reverse": true})).unwrap()["ids"], json!([target.0])); + assert_eq!(node(&s, target).appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); +} + +#[test] +fn invert_and_complement_recolor_a_picked_object_for_its_instance_only() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let r = s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(symbol_part_fill(&s, inst), "#ffff00", "the picked object's own blue, inverted"); + assert_eq!(overrides(&s, inst), 1, "only the picked object changes"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert!(*s.doc().unwrap().doc.symbols[0].art == *art, "the symbol's art stays"); + assert_eq!(pixel(&s, 80, 30), [255, 255, 0, 255], "the changed object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + s.execute("paint.complement", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#0000ff", "the complement of yellow"); + assert_eq!(undo_steps(&s), undo + 2, "one undo step"); +} + +/// One change holds one paint, so Invert and Complement are refused for type whose characters +/// differ in color, and each character's color stays. A fill gives all of them one color. +#[test] +fn invert_and_complement_are_refused_for_picked_type_whose_characters_differ() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let NodeKind::Text(tx) = &mut art.kind else { panic!("type: {art:?}") }; + let mut second = tx.runs[0].clone(); + second.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + tx.runs.push(second); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#ff00ff", "one color inverts"); +} + +/// The color of the fill instance `inst`'s first change sets. +fn symbol_part_fill(s: &Session, inst: NodeId) -> String { + let o = node(s, inst).extras().symbol_overrides[0].clone(); + o.fill.as_ref().and_then(|p| p.color()).map(|c| c.to_hex()).unwrap_or_else(|| panic!("a solid fill: {o:?}")) +} + +#[test] +fn a_stroke_on_a_picked_type_object_without_a_weight_draws() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setStroke", &json!({"ids": [t.0], "none": true})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let part = s.doc().unwrap().selected_symbol_part().unwrap(); + let NodeKind::Text(tx) = &part.kind else { panic!("type: {part:?}") }; + assert!(tx.runs.iter().all(|r| r.style.stroke_width == 1.0 && !r.style.stroke.is_none()), "{:?}", tx.runs); +} + +/// The colors the Fill and Stroke proxies show, as `paint.proxies` replies them. +fn proxy_colors(s: &mut Session) -> (Option, Option) { + let r = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!((r["fillMixed"].clone(), r["strokeMixed"].clone()), (json!(false), json!(false)), "{r}"); + let (fill, stroke) = s.proxy_paints(); + assert_eq!((r["fill"].clone(), r["stroke"].clone()), (json!(fill), json!(stroke))); + (fill.color().map(|c| c.to_hex()), stroke.color().map(|c| c.to_hex())) +} + +#[test] +fn the_fill_and_stroke_proxies_show_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None), "the symbol's own paint"); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#ff0000".into()), Some("#00ff00".into())), "the object's own paint"); + // The other object of the same instance shows the symbol's paint. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None)); +} + +#[test] +fn selecting_a_guide_too_lets_go_of_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap()["index"].as_u64().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [g], "toggle": true})).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.guides.len(), sel.symbol_part), (vec![inst], 1, None)); + // Paint now goes to the instance, not to the object. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(overrides(&s, inst), 0); + // Pruning a selection with a guide in it lets go of the object as well. + let mut sel = s.doc().unwrap().selection.clone(); + sel.symbol_part = Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] }); + sel.prune(&s.doc().unwrap().doc); + assert_eq!(sel.symbol_part, None); +} + +/// A dynamic symbol with 9-slice guides at 25%: a 100 × 100 frame at (50, 50), a 10 × 10 square +/// in its top-left corner and one in its center; its instance resized to 300 × 100. +fn sliced_panel() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let rect = + |s: &mut Session, x: f64, y: f64, w: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()).0; + let ids = [rect(&mut s, 50.0, 50.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0)]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Panel", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + (s, inst) +} + +#[test] +fn direct_selection_picks_and_outlines_the_object_where_a_9_slice_instance_draws_it() { + let (mut s, inst) = sliced_panel(); + let part_box = |s: &Session, path: &[u32]| { + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), path).unwrap(); + vectorcraft_geom::kurbo::Shape::bounding_box(&o) + }; + let close = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6); + // The center cell (75..125 of the art) is drawn at 75..325, so the center square at 175..225; + // placed by the instance's transform alone it would be at 185..215. + assert!(close(part_box(&s, &[2]), Rect::new(175.0, 95.0, 225.0, 105.0)), "{:?}", part_box(&s, &[2])); + assert!(close(part_box(&s, &[1]), Rect::new(50.0, 50.0, 60.0, 60.0)), "the corner keeps its size: {:?}", part_box(&s, &[1])); + direct(&mut s, (177.0, 100.0), (177.0, 100.0), Mods::default()); + assert_eq!(s.doc().unwrap().selection.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![2] })); + direct(&mut s, (165.0, 100.0), (165.0, 100.0), Mods::default()); + let picked = s.doc().unwrap().selection.symbol_part.clone().map(|p| p.path); + assert_ne!(picked, Some(vec![2]), "where the square is not drawn"); +} + +/// An object that is drawn once in each 9-slice cell it reaches, rather than cut at the guides +/// (here area type), is outlined where its copies are drawn. +#[test] +fn the_outline_of_an_object_drawn_in_each_9_slice_cell_matches_what_is_drawn() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let frame = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()).0; + let area = json!({"width": 80, "height": 10}); + let text = id(&s.execute("text.create", &json!({"x": 60, "y": 95, "area": area, "size": 6, "text": "Hi"})).unwrap()).0; + let ids = [frame, text]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Bar", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), &[1]).unwrap(); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&o); + // The left copy is drawn at 60..75, the center one (placed whole it reaches 0..400) at 75..325 + // and the right one at 325..340. + let want = Rect::new(60.0, 95.0, 340.0, 105.0); + assert!([(b.x0, want.x0), (b.y0, want.y0), (b.x1, want.x1), (b.y1, want.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}"); +} + +#[test] +fn direct_selection_picks_a_static_instance_whole_as_before() { + let mut s = session(); + let inst = pair(&mut s, "static"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.symbol_part), (vec![inst], None)); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).is_err(), "a static symbol"); + // A dynamic one: Alt picks the instance whole, and a path naming no object is refused. + s.execute("symbol.options", &json!({"name": "Pair", "type": "dynamic"})).unwrap(); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..Mods::default() }); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [2]})).is_err()); + assert!(s.execute("symbol.instance.selectPart", &json!({"path": [1]})).is_err(), "no id"); + // Making the symbol static again lets go of the picked object. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("symbol.options", &json!({"name": "Pair", "type": "static"})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); +} + #[test] fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before() { let mut s = session(); @@ -191,3 +862,201 @@ fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before assert_eq!(back.node(inst).unwrap().extras().symbol_overrides, doc.node(inst).unwrap().extras().symbol_overrides); assert_eq!(back.symbols, doc.symbols); } + +#[test] +fn a_swatch_edit_recolors_a_picked_objects_own_paints() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + let fill = s.doc().unwrap().selected_symbol_part().unwrap().appearance.fill_paint(); + assert_eq!(fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "{fill:?}"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +#[test] +fn a_swatch_or_color_only_a_picked_objects_paint_uses_counts_as_used() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone(); + assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand"))); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert!(!unused(&mut s).as_array().unwrap().contains(&json!("Brand")), "only the object's own paint uses it"); + let colors = s.execute("recolor.colors", &json!({"ids": [inst.0]})).unwrap(); + assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000" && c["swatch"] == "Brand"), "{colors}"); + // A swatch edit maps the same paint once. + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); +} + +/// The Eyedropper takes a picked object's paints only from a path, compound path or type. +#[test] +fn the_eyedropper_refuses_a_source_that_is_not_painted_art() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let (a, b) = (square(&mut s, 150.0), square(&mut s, 150.0)); + let group = id(&s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let (fill, stroke, own, undo) = (s.paint.fill.clone(), s.paint.stroke.clone(), node(&s, inst).extras().symbol_overrides.clone(), undo_steps(&s)); + s.eyedropper_sample = None; + for source in [inst, other, group] { + let e = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + } + // A click with the tool on an instance, the picked one included. + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("eyedropper", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 30.0), v).unwrap(); + assert_eq!(node(&s, inst).extras().symbol_overrides, own, "the object keeps its paints"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "the defaults for new art stay"); + assert!(s.eyedropper_sample.is_none(), "nothing was sampled"); + assert_eq!(pixel(&s, 80, 30), RED); +} + +/// A path of a compound path gives the paint of the compound path, which holds it. +#[test] +fn the_eyedropper_samples_a_compound_path_through_its_member() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let (a, b) = (square(&mut s, 120.0), square(&mut s, 170.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [compound.0], "color": "#00ff00"})).unwrap(); + let member = node(&s, compound).children().unwrap()[0].id; + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": member.0})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +/// A Cmd-drag lends the Selection tool, or with Alt Group Selection, which act on the whole instance. +#[test] +fn a_lent_selection_tool_moves_the_instance_of_a_picked_object() { + let cmd = Mods { cmd: true, ..Mods::default() }; + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + direct(&mut s, (80.0, 30.0), (100.0, 40.0), cmd); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); + + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..cmd }); + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "Group Selection selects the instance whole"); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); + // A plain Direct Selection click picks the object again once the tool is back. + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); +} + +/// The commands that change nothing in the document read the instance of a picked object, so the +/// Attributes and CSS Properties panels and the others keep showing it. +#[test] +fn read_only_commands_read_the_instance_of_a_picked_object() { + let mut failures = Vec::new(); + for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal) { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + if let Err(e) = s.execute(spec.id, &json!({})) + && e.to_string().contains("symbol instance") + { + failures.push(format!("{}: {e}", spec.id)); + } + if s.doc().is_ok_and(|st| st.doc != doc) { + failures.push(format!("{}: the document changed", spec.id)); + } + // A command run after one of these is refused as before. + if s.doc().is_ok_and(|st| st.selection.symbol_part.is_some()) && s.execute("object.hide", &json!({})).is_ok() { + failures.push(format!("{}: the refusal stays off afterward", spec.id)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("css.selection", &json!({})).unwrap(); + s.execute("attributes.info", &json!({})).unwrap(); +} + +/// Export CSS writes a file and changes nothing in the document, so it reads the instance of a +/// picked object as it reads an instance selected whole, and the next edit is refused as before. +#[test] +fn css_export_reads_the_instance_of_a_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let whole = s.execute("css.export", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let picked = s.execute("css.export", &json!({})).unwrap(); + assert_eq!(picked["rules"], 1); + assert_eq!(picked["data"], whole["data"]); + assert!(s.doc().unwrap().doc == doc, "the document changed"); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + assert!(s.execute("object.hide", &json!({})).is_err()); +} + +/// A drag that moved nothing leaves Transform Again repeating the last real transformation. +#[test] +fn a_refused_drag_of_a_picked_object_is_not_kept_for_transform_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(s.doc().unwrap().last_transform.is_none(), "{:?}", s.doc().unwrap().last_transform); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); +} + +/// A bad `target` or `aligned` on a paint command is refused with an object picked too. +#[test] +fn bad_paint_parameters_leave_a_picked_object_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for (cmd, p) in [ + ("paint.setFill", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.setStroke", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.invert", json!({"target": "bogus"})), + ("paint.complement", json!({"target": "bogus"})), + ("paint.setFill", json!({"color": "#ff0000", "aligned": "yes"})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`target` must") || e.contains("aligned"), "{cmd} {p}: {e}"); + assert_eq!(overrides(&s, inst), 0, "{cmd} {p}"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); +} + +/// An object picked while Replace read its file: the instance selected when Place started is +/// selected again first, so a replay of the journal replaces it. +#[test] +fn a_replace_that_finishes_after_an_object_was_picked_selects_the_instance_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let svg = ""; + let p = json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes()), "replace": true}); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.place_read(read).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); + let last: Vec<_> = s.journal.iter().rev().take(2).rev().map(|(id, _)| id.clone()).collect(); + assert_eq!(last, ["select.set", "file.place"]); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c1c4df67d..fa389583f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -117,6 +117,8 @@ impl Session { // `select_tool` calls its [`Tool::chosen_again`] (the Blend tool finishes its blend). let again = self.lender.as_ref().unwrap_or(&self.tool).id() == id; self.switch_tool(id, view)?; + // `switch_tool` returns early for the tool already active. + self.select_whole_instance(id); if again { let acts = self.with_tool_cx(view, |t, cx| t.chosen_again(cx)); self.apply_actions(acts)?; @@ -147,10 +149,24 @@ impl Session { } if vectorcraft_tools::catalog::is_selection_tool(id) { self.last_selection_tool = Some(self.tool.id()); + self.select_whole_instance(id); } Ok(()) } + /// The selection tools other than Direct Selection pick whole objects: with one of them chosen, + /// an object selected in a symbol instance's art gives way to the instance. The revision + /// changes with it, so panels that cache by revision show the instance. + fn select_whole_instance(&mut self, id: &str) { + if id != "directSelection" + && vectorcraft_tools::catalog::is_selection_tool(id) + && let Some(st) = self.active_mut() + && st.selection.symbol_part.take().is_some() + { + st.revision += 1; + } + } + /// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is, /// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others. /// Cmd+Alt lends Group Selection while Direct Selection is active. Cmd still lends the last @@ -200,6 +216,8 @@ impl Session { // Fails only without a document, and then there is no typing to keep. let _ = crate::cmd::settle_interaction(self); } + // The Selection and Group Selection tools act on the instance whole. + self.select_whole_instance(id); let lent = self.make_tool(id); self.lender = Some(std::mem::replace(&mut self.tool, lent)); PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev } diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index d27a9f2a4..a2ffbbf87 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -41,6 +41,11 @@ //! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]). //! A press on a selected object's centre point (Attributes panel › Show Center) selects the object //! whole, and a drag from it moves the object. +//! A click on an instance of a dynamic symbol selects the object of the symbol's art under the +//! pointer (`symbol.instance.selectPart`), outlined: the solid color Fill, Stroke, Color, Swatches +//! and Eyedropper changes then go to that object for this instance only, and a drag moves nothing. +//! The engine refuses any object but a path, compound path or type. Alt and Shift pick the instance +//! whole, as for other objects. //! //! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors //! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else @@ -50,8 +55,8 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at}; -use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection}; +use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline}; +use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection, symbol_overrides}; use vectorcraft_geom::{Anchor, PathData, PathSeg, Point, Rect, Vec2}; use crate::bbox::move_delta; @@ -667,6 +672,12 @@ fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option { .find(|id| !cx.selection.contains(*id)) } +/// Is an object of instance `id`'s art selected ([`vectorcraft_doc::Selection::symbol_part`]), +/// rather than the instance whole? +fn part_picked(cx: &ToolContext, id: NodeId) -> bool { + cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == id) +} + /// The segment of path `id` nearest to `p`, where the hit test found its stroke. fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { let pd = cx.doc.node(id)?.path_data()?; @@ -984,6 +995,11 @@ impl Tool for DirectSelectionTool { let mut out = self.spine_overlays(cx); out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { + if let Some(sp) = &cx.selection.symbol_part + && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline(cx.doc, n, &sp.path)) + { + out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); + } out.extend(frame_overlays(cx, &self.frame_anchors(cx))); let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None }; out.extend(pathtype::overlays(cx, self.bracket_hover, drag)); @@ -1309,7 +1325,7 @@ impl DirectSelectionTool { if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; } - if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) { + if cx.selection.partial(id).is_some() || cx.selection.segments.contains_key(&id) || part_picked(cx, id) { return vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; } vec![] @@ -1319,6 +1335,26 @@ impl DirectSelectionTool { h.ancestry.push(part); h.leaf = part; } + // On an instance of a dynamic symbol a click picks the object of its art under the pointer; + // a drag from it moves nothing (`object.transform` refuses while it is selected). + if !ev.mods.alt + && !ev.mods.shift + && let Some(n) = cx.doc.node(h.leaf) + && let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && let Some(sym) = cx.doc.symbols.iter().find(|s| s.name == *symbol && s.dynamic) + && let Some(path) = symbol_part_at(cx.doc, n, p, cx.selection_hit_options()) + { + self.frame.clear(); + // The engine refuses any other object with a status message, and the selection stays, + // so a drag from the press does not move it. + if symbol_overrides::part(&sym.art, &path).is_some_and(symbol_overrides::takes_part_paint) { + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; + } + if cx.selection.symbol_part.as_ref().is_some_and(|sp| sp.instance == h.leaf && sp.path == path) { + return vec![]; + } + return vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": h.leaf.0, "path": path}))]; + } // On a gradient mesh a click picks the point, the mesh line's two ends or the four points // around the patch under it (Shift toggles them), and selects the mesh; a drag moves the // picked points. @@ -1340,7 +1376,7 @@ impl DirectSelectionTool { // A segment pick keeps its endpoints available for movement without selecting // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks // the whole path (and Alt-drag copies it), as with Group Selection. - let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none() && !part_picked(cx, h.leaf); if !ev.mods.alt && matches!(h.kind, HitKind::Stroke | HitKind::Outline) && let Some(s) = segment_at(cx, h.leaf, p) @@ -1376,7 +1412,7 @@ impl DirectSelectionTool { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; } self.frame.clear(); - if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() { + if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() || part_picked(cx, h.leaf) { return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))]; } vec![] diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index a8507e693..42396e241 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -2147,3 +2147,165 @@ fn selected_path_handle_over_background_rect_is_draggable() { let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert_eq!(acts, vec![Action::Begin("Reshape".into())]); } + +/// On an instance of a dynamic symbol, a Direct Selection click picks the object of the symbol's +/// art under the pointer, which is then outlined; Alt and Shift, a static symbol and Group +/// Selection pick the instance whole, as before. +#[test] +fn a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |id, x: f64| { + Arc::new(Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art())) + }; + let art = Node::group(NodeId(0), vec![square(0, 0.0), square(0, 20.0)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Pair".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)) * vectorcraft_geom::Affine::scale(2.0); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Pair".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |d: &vectorcraft_doc::Document, sel: &Selection, group: bool, x: f64, y: f64, m: Mods| { + DirectSelectionTool::new(group).pointer(&cx(d, sel, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(m)) + }; + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The second square is drawn at 340..360. + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), part(json!([1]))); + assert_eq!(down(&d, &none, false, 310.0, 310.0, Mods::default()), part(json!([0]))); + let whole = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt picks the instance"); + let shift = down(&d, &none, false, 350.0, 310.0, Mods { shift: true, ..Mods::default() }); + assert_eq!(shift, vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]); + assert_eq!(down(&d, &none, true, 350.0, 310.0, Mods::default()), whole, "Group Selection picks the instance"); + // The part selected: a click on it again keeps it, and it is outlined. + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods::default()), vec![]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(340.0, 300.0, 360.0, 320.0))); + // With the part selected, an Alt-click on the instance, or a press on its center point, picks + // the instance whole again. + assert_eq!(down(&d, &sel, false, 350.0, 310.0, Mods { alt: true, ..Mods::default() }), whole, "Alt with the part selected"); + d.node_mut(id).unwrap().edit_attrs(|a| a.show_center = Some(true)); + assert_eq!(down(&d, &sel, false, 330.0, 310.0, Mods::default()), whole, "the center point with the part selected"); + // A static symbol: the instance is picked whole. + d.symbols[0].dynamic = false; + assert_eq!(down(&d, &none, false, 350.0, 310.0, Mods::default()), whole); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a locked object: it picks the +/// unlocked object beneath it, and where only a locked object is under the pointer it picks the +/// instance whole. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_locked_objects() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |x: f64, locked: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default_art()); + n.locked = locked; + Arc::new(n) + }; + // The unlocked square at 0..10 has a locked square over it; the square at 20..30 is locked. + let art = Node::group(NodeId(0), vec![square(0.0, false), square(0.0, true), square(20.0, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Locked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Locked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(305.0, 305.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [0]}))]); + assert_eq!(down(325.0, 305.0), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + // A locked clipping path still clips, but a press on its outline picks the art it clips. + let mut clip = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + clip.locked = true; + let art = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(-10.0, -10.0, 30.0, 30.0)), Appearance::default_art()); + let masked = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + let mut d = d.clone(); + d.symbols[0].art = Arc::new(masked); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(down(319.0, 310.0), vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": [1]}))]); +} + +/// In a dynamic symbol's art, a Direct Selection press passes over a guide path while guides are +/// hidden or locked and picks the square beneath it; with guides shown and unlocked it picks the +/// guide path. The same goes for a press on the edge of a guide used as a clipping path. +#[test] +fn a_press_on_a_dynamic_instance_passes_over_guides_while_they_cannot_be_picked() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let square = |r: Rect, is_guide: bool| { + let mut n = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); + let NodeKind::Path { guide, .. } = &mut n.kind else { panic!("Node::path makes a path") }; + *guide = is_guide; + Arc::new(n) + }; + let small = Rect::new(0.0, 0.0, 10.0, 10.0); + let art = Node::group(NodeId(0), vec![square(small, false), square(small, true)]); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Guided".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Guided".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + let down = |d: &vectorcraft_doc::Document, guides: bool, x: f64, y: f64| { + let c = ToolContext { guides, ..cx(d, &none, &p) }; + DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)) + }; + assert_eq!(down(&d, false, 305.0, 305.0), part(json!([0])), "guides off: the square beneath"); + assert_eq!(down(&d, true, 305.0, 305.0), part(json!([1])), "guides on: the guide"); + // A guide gives a clip group no clip region, so nothing in the group is hit away from its edge, + // as outside symbols. With guides off a press on its edge picks the instance whole. + let clip = Rect::new(0.0, 0.0, 20.0, 20.0); + let masked = + Node::new(NodeId(0), NodeKind::Group { children: vec![square(clip, true), square(Rect::new(-10.0, -10.0, 30.0, 30.0), false)], clip: true }); + d.symbols[0].art = Arc::new(masked); + assert_eq!( + down(&d, false, 319.0, 310.0), + vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))], + "guides off: the clipping path's edge" + ); + assert_eq!(down(&d, true, 319.0, 310.0), part(json!([0])), "guides on: the clipping path's edge"); +} + +/// In a dynamic symbol's art, a Direct Selection press on the outline of a clip group's clipping +/// path picks the clipping path, also where the art it clips covers the path's edge; inside the +/// mask away from the edge the art is picked. +#[test] +fn a_press_on_a_clipping_path_in_a_dynamic_instance_picks_it() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, Symbol}; + let rect = |r: Rect| Arc::new(Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())); + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(-10.0, -10.0, 30.0, 30.0))], clip: true }, + ); + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.symbols.push(Symbol { dynamic: true, name: "Masked".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let xf = vectorcraft_geom::Affine::translate((300.0, 300.0)); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Masked".into(), xf })).unwrap(); + let (none, p) = (Selection::default(), paint()); + let down = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, x, y)); + let part = |path: Value| vec![Action::Exec("symbol.instance.selectPart".into(), json!({"id": id.0, "path": path}))]; + // The clipping path's right edge is at x = 320; the art covers it. + assert_eq!(down(321.0, 310.0), part(json!([0])), "just outside the mask, on its edge"); + assert_eq!(down(319.0, 310.0), part(json!([0])), "just inside the mask, on its edge"); + assert_eq!(down(310.0, 310.0), part(json!([1])), "inside the mask, the art"); + // Clipped art from 10 to 30 picked: its outline is cut to the clipping path's bounds (0 to 20), + // where it is picked. + let art = Node::new( + NodeId(0), + NodeKind::Group { children: vec![rect(Rect::new(0.0, 0.0, 20.0, 20.0)), rect(Rect::new(10.0, 10.0, 30.0, 30.0))], clip: true }, + ); + d.symbols[0].art = Arc::new(art); + let mut sel = Selection::default(); + sel.set_symbol_part(id, vec![1]); + let outline = DirectSelectionTool::new(false).overlays(&cx(&d, &sel, &p)); + let drawn = + outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None }); + assert_eq!(drawn, Some(Rect::new(310.0, 310.0, 320.0, 320.0))); +} diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 7a9b59e2d..d984504e1 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -444,6 +444,11 @@ impl GradientTool { let id = paint_owner(cx.doc, h.leaf); let Some(v) = cx.selection.objects.contains(&id).then(|| click_vector(cx, id)).flatten() else { return vec![] }; let mut params = Self::geom_params(cx, p, p + v); + // Without `ids`, the engine refuses the gradient while an object of a dynamic symbol + // instance's art is selected. + if cx.selection.symbol_part.is_some() { + return vec![Action::Exec("paint.setGradientGeom".into(), params)]; + } params["ids"] = json!([id.0]); // With `ids` the engine edits the topmost fill or stroke unless an item is named. if let Some(i) = cx.doc.node(id).and_then(|n| n.appearance.item_of_kind(cx.appearance_item, cx.fill_active)) { diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs index 2542ec7c5..c6582152b 100644 --- a/crates/tools/src/xform/gradient/tests.rs +++ b/crates/tools/src/xform/gradient/tests.rs @@ -268,6 +268,31 @@ fn a_click_inside_the_art_applies_the_gradient_from_there() { assert!(t.pointer(&cx, &ev(PointerKind::Up, Point::new(400.0, 400.0))).is_empty()); } +/// With an object of a dynamic symbol instance selected with Direct Selection, a click on the +/// instance sends the gradient without `ids`, which the engine refuses. +#[test] +fn a_click_on_a_dynamic_instance_with_a_picked_object_names_no_object() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, Symbol}; + let square = + Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let art = Node::group(NodeId(0), vec![Arc::new(square)]); + let (mut d, _) = doc_with_rect(); + d.symbols.push(Symbol { dynamic: true, name: "One".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "One".into(), xf: Affine::translate((300.0, 300.0)) })).unwrap(); + let mut s = Selection::default(); + s.set_symbol_part(id, vec![0]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GradientTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, Point::new(305.0, 305.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(305.0, 305.0))); + let (c, v) = exec(&a[0]); + assert_eq!(c, "paint.setGradientGeom"); + assert!(v.get("ids").is_none() && v.get("item").is_none(), "{v}"); +} + #[test] fn double_click_on_a_stop_opens_its_popover() { let (d, s) = graded(two()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 981975808..6cc8fb578 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6229,6 +6229,7 @@ breakText Glyphs Глифи Reset Symbol Instance Нулиране на екземпляра на символ @msg type must be static or dynamic, not {v} type трябва да е static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` е статичен символ: първо задайте типа на символа на „Динамичен“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change На път, съставен път или текст, избран в екземпляр на символ, могат да се променят само плътните цветове на запълването и контура @msg path must be a list of child indices path трябва да е списък с индекси на дъщерни обекти @msg no object at path {shown} in the symbol's art в графиката на символа няма обект на пътя {shown} @msg give fill, stroke, opacity and/or blend задайте fill, stroke, opacity и/или blend diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4fd69733d..39a73d96a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6278,6 +6278,7 @@ shape Open Otvoren Reset Symbol Instance Vrati instancu simbola na početno @msg type must be static or dynamic, not {v} type mora biti static ili dynamic, a ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statički simbol: prvo postavite vrstu simbola na Dinamički +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Putanji, složenoj putanji ili tekstu odabranom u instanci simbola mogu se mijenjati samo pune boje ispune i poteza @msg path must be a list of child indices path mora biti lista indeksa podređenih objekata @msg no object at path {shown} in the symbol's art nema objekta na putanji {shown} u grafici simbola @msg give fill, stroke, opacity and/or blend navedite fill, stroke, opacity i/ili blend diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f2a467fc5..3f61c46c3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6389,6 +6389,7 @@ breakText Glyphs Glifs Reset Symbol Instance Restableix la instància de símbol @msg type must be static or dynamic, not {v} type ha de ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` és un símbol estàtic: primer establiu el tipus de símbol a Dinàmic +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change D'un traçat, un traçat compost o un text seleccionat en una instància de símbol només es pot canviar el color sòlid de l'emplenament i el traç @msg path must be a list of child indices path ha de ser una llista d’índexs de fills @msg no object at path {shown} in the symbol's art no hi ha cap objecte al camí {shown} de l’art del símbol @msg give fill, stroke, opacity and/or blend indiqueu fill, stroke, opacity i/o blend diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 15cd33ed0..9291741e3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6406,6 +6406,7 @@ breakText Glyphs Glyfy Reset Symbol Instance Obnovit instanci symbolu @msg type must be static or dynamic, not {v} type musí být static nebo dynamic, ne {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statický symbol: nejprve nastavte jeho typ symbolu na Dynamický +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change U cesty, složené cesty nebo textu vybraného v instanci symbolu lze měnit jen plnou barvu výplně a tahu @msg path must be a list of child indices path musí být seznam indexů potomků @msg no object at path {shown} in the symbol's art v kresbě symbolu není na cestě {shown} žádný objekt @msg give fill, stroke, opacity and/or blend zadejte fill, stroke, opacity a/nebo blend diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 978a0971a..5f5d188ed 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6243,6 +6243,7 @@ breakText Glyphs Glyffer Reset Symbol Instance Nulstil symbolforekomst @msg type must be static or dynamic, not {v} type skal være static eller dynamic, ikke {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` er et statisk symbol: indstil først symboltypen til Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change For en kurve, sammensat kurve eller tekst, der er markeret i en symbolforekomst, kan kun ensfarvet fyld og streg ændres @msg path must be a list of child indices path skal være en liste over underordnede indekser @msg no object at path {shown} in the symbol's art intet objekt ved stien {shown} i symbolets grafik @msg give fill, stroke, opacity and/or blend angiv fill, stroke, opacity og/eller blend diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e61e26c81..e42790f99 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5992,6 +5992,7 @@ breakText Glyphs Glyphen Reset Symbol Instance Symbolinstanz zurücksetzen @msg type must be static or dynamic, not {v} type muss static oder dynamic sein, nicht {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` ist ein statisches Symbol: Stellen Sie zuerst den Symboltyp auf „Dynamisch“ ein +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bei einem in einer Symbolinstanz ausgewählten Pfad, zusammengesetzten Pfad oder Text lässt sich nur die Vollfarbe von Fläche und Kontur ändern @msg path must be a list of child indices path muss eine Liste von Kindindizes sein @msg no object at path {shown} in the symbol's art kein Objekt am Pfad {shown} in der Grafik des Symbols @msg give fill, stroke, opacity and/or blend geben Sie fill, stroke, opacity und/oder blend an diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bad5543ba..ddc22b961 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6358,6 +6358,7 @@ breakText Glyphs Γλύφοι Reset Symbol Instance Επαναφορά στιγμιότυπου συμβόλου @msg type must be static or dynamic, not {v} το type πρέπει να είναι static ή dynamic, όχι {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first το `{name}` είναι στατικό σύμβολο: ορίστε πρώτα τον τύπο συμβόλου σε Δυναμικό +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Σε διαδρομή, σύνθετη διαδρομή ή κείμενο που έχει επιλεγεί σε στιγμιότυπο συμβόλου αλλάζει μόνο το συμπαγές χρώμα του γεμίσματος και της πινελιάς @msg path must be a list of child indices το path πρέπει να είναι λίστα δεικτών θυγατρικών αντικειμένων @msg no object at path {shown} in the symbol's art δεν υπάρχει αντικείμενο στη διαδρομή {shown} στο γραφικό του συμβόλου @msg give fill, stroke, opacity and/or blend δώστε fill, stroke, opacity ή/και blend diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 651886e05..531aaf18e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6348,6 +6348,7 @@ breakText Glyphs Glifos Reset Symbol Instance Restablecer instancia de símbolo @msg type must be static or dynamic, not {v} type debe ser static o dynamic, no {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` es un símbolo estático: primero establezca su tipo de símbolo en Dinámico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De un trazado, trazado compuesto o texto seleccionado en una instancia de símbolo solo se puede cambiar el color sólido del relleno y del trazo @msg path must be a list of child indices path debe ser una lista de índices de hijos @msg no object at path {shown} in the symbol's art no hay ningún objeto en la ruta {shown} del arte del símbolo @msg give fill, stroke, opacity and/or blend indique fill, stroke, opacity y/o blend diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f79d04cea..6ae295376 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6436,6 +6436,7 @@ breakText Glyphs Merkit Reset Symbol Instance Palauta symboli-ilmentymä @msg type must be static or dynamic, not {v} type-arvon on oltava static tai dynamic, ei {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` on staattinen symboli: aseta ensin sen symbolin tyypiksi Dynaaminen +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Symboli-ilmentymästä valitusta polusta, yhdistelmäpolusta tai tekstistä voi muuttaa vain täytön ja viivan yhtenäistä väriä @msg path must be a list of child indices path-arvon on oltava luettelo alikohteiden indeksejä @msg no object at path {shown} in the symbol's art symbolin kuvituksessa ei ole objektia polussa {shown} @msg give fill, stroke, opacity and/or blend anna fill, stroke, opacity ja/tai blend diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b9c9c1165..fdad1035b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6401,6 +6401,7 @@ breakText Glyphs Glyphes Reset Symbol Instance Réinitialiser l'instance de symbole @msg type must be static or dynamic, not {v} type doit être static ou dynamic, et non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` est un symbole statique : définissez d'abord son type de symbole sur Dynamique +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Seules les couleurs unies du fond et du contour d'un tracé, d'un tracé transparent ou d'un texte sélectionné dans une instance de symbole peuvent changer @msg path must be a list of child indices path doit être une liste d'indices d'enfants @msg no object at path {shown} in the symbol's art aucun objet au chemin {shown} dans l'illustration du symbole @msg give fill, stroke, opacity and/or blend indiquez fill, stroke, opacity et/ou blend diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9f6c6807f..efff1b054 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5473,6 +5473,7 @@ @msg `{name}` is a built-in workspace; choose another name `{name}` एक अंतर्निहित वर्कस्पेस है; एक और नाम चुनें @msg `{name}` is a process colour: tints apply to global and spot colours `{name}` एक प्रक्रिया रंग है: टिंट वैश्विक और स्पॉट रंगों पर लागू होते हैं @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` एक स्थिर सिंबल है: अपने सिंबल प्रकार को पहले गतिशील करने के लिए सेट करें +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change सिंबल इंस्टेंस में चुने गए पाथ, कंपाउंड पाथ या टाइप का केवल ठोस रंग वाला फ़िल और स्ट्रोक बदला जा सकता है @msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` `{base}` का एक टिंट है: `{base}` संपादित करें (केवल एक टिंट swatch केवल एक नया नाम लेता है) @msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` वर्किंग स्पेस है: पहले edit.colorSettings से दूसरा चुनें @msg `{name}` is built in and stays `{name}` बनाया गया है और रहता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2de1183cc..3153fae2a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6390,6 +6390,7 @@ breakText Glyphs Glifák Reset Symbol Instance Szimbólumpéldány visszaállítása @msg type must be static or dynamic, not {v} a type értéke static vagy dynamic lehet, nem {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statikus szimbólum: előbb állítsa a szimbólum típusát Dinamikusra +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Egy szimbólumpéldányban kijelölt útvonalnak, összetett útvonalnak vagy szövegnek csak a kitöltés és a körvonal egyszínű színe módosítható @msg path must be a list of child indices a path gyermekindexek listája kell legyen @msg no object at path {shown} in the symbol's art a szimbólum grafikájában nincs objektum a(z) {shown} útvonalon @msg give fill, stroke, opacity and/or blend adja meg a fill, stroke, opacity és/vagy blend értékét diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bffe798c5..9ba667353 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6243,6 +6243,7 @@ breakText Glyphs Glif Reset Symbol Instance Atur ulang instans simbol @msg type must be static or dynamic, not {v} type harus static atau dynamic, bukan {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` adalah simbol statis: setel dulu jenis simbolnya ke Dinamis +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Hanya warna solid isian dan goresan jalur, jalur majemuk, atau teks yang dipilih dalam instans simbol yang dapat diubah @msg path must be a list of child indices path harus berupa daftar indeks anak @msg no object at path {shown} in the symbol's art tidak ada objek di jalur {shown} dalam karya seni simbol @msg give fill, stroke, opacity and/or blend berikan fill, stroke, opacity dan/atau blend diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 49f8130ae..bfef006c9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6026,6 +6026,7 @@ breakText Glyphs Glifi Reset Symbol Instance Reimposta istanza di simbolo @msg type must be static or dynamic, not {v} type deve essere static o dynamic, non {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` è un simbolo statico: imposta prima il tipo di simbolo su Dinamico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Di un tracciato, un tracciato composto o un testo selezionato in un'istanza di simbolo si può modificare solo il colore pieno di riempimento e traccia @msg path must be a list of child indices path deve essere un elenco di indici figlio @msg no object at path {shown} in the symbol's art nessun oggetto al percorso {shown} nella grafica del simbolo @msg give fill, stroke, opacity and/or blend specifica fill, stroke, opacity e/o blend diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 94b2e408a..07ce883e6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6091,6 +6091,7 @@ breakText Glyphs 字形 Reset Symbol Instance シンボルインスタンスをリセット @msg type must be static or dynamic, not {v} type は static または dynamic にしてください({v} は無効です) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` はスタティックシンボルです。先にシンボルの種類をダイナミックに設定してください +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change シンボルインスタンス内で選択したパス、複合パス、テキストは、塗りと線の単色のみ変更できます @msg path must be a list of child indices path は子のインデックスのリストにしてください @msg no object at path {shown} in the symbol's art シンボルのアートのパス {shown} にオブジェクトがありません @msg give fill, stroke, opacity and/or blend fill、stroke、opacity、blend のいずれかを指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 77b99070f..5d049fb9e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6403,6 +6403,7 @@ breakText Glyphs გლიფები Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა @msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change სიმბოლოს ეგზემპლარში მონიშნულ ტრაექტორიას, შედგენილ ტრაექტორიას ან ტექსტს მხოლოდ შევსებისა და კონტურის მთლიანი ფერი შეეცვლება @msg path must be a list of child indices path უნდა იყოს შვილობილი ინდექსების სია @msg no object at path {shown} in the symbol's art სიმბოლოს გრაფიკაში გზაზე {shown} ობიექტი არ არის @msg give fill, stroke, opacity and/or blend მიუთითეთ fill, stroke, opacity და/ან blend diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca570d3cc..5cf97c257 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5987,6 +5987,7 @@ breakText Glyphs 글리프 Reset Symbol Instance 심볼 인스턴스 재설정 @msg type must be static or dynamic, not {v} type은 static 또는 dynamic이어야 합니다({v}는 안 됨) @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}`은(는) 정적 심볼입니다. 먼저 심볼 유형을 동적으로 설정하세요 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 심볼 인스턴스에서 선택한 패스, 컴파운드 패스 또는 문자는 칠과 선의 단색만 변경할 수 있습니다 @msg path must be a list of child indices path는 자식 인덱스 목록이어야 합니다 @msg no object at path {shown} in the symbol's art 심볼 아트의 경로 {shown}에 개체가 없습니다 @msg give fill, stroke, opacity and/or blend fill, stroke, opacity 및/또는 blend를 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bfbdef06c..5a16c754c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6289,6 +6289,7 @@ breakText Glyphs Glyphs Reset Symbol Instance Symboolinstantie opnieuw instellen @msg type must be static or dynamic, not {v} type moet static of dynamic zijn, niet {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` is een statisch symbool: stel eerst het symbooltype in op Dynamisch +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Van een pad, samengesteld pad of tekst die in een symboolinstantie is geselecteerd, kan alleen de effen kleur van vulling en lijn veranderen @msg path must be a list of child indices path moet een lijst met onderliggende indexen zijn @msg no object at path {shown} in the symbol's art geen object op pad {shown} in de illustratie van het symbool @msg give fill, stroke, opacity and/or blend geef fill, stroke, opacity en/of blend op diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 668a55ed7..e218ac53c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6261,6 +6261,7 @@ breakText Glyphs Glify Reset Symbol Instance Resetuj wystąpienie symbolu @msg type must be static or dynamic, not {v} type musi mieć wartość static lub dynamic, a nie {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` jest symbolem statycznym: najpierw ustaw jego typ symbolu na Dynamiczny +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change W ścieżce, ścieżce złożonej lub tekście zaznaczonym w wystąpieniu symbolu można zmienić tylko jednolity kolor wypełnienia i obrysu @msg path must be a list of child indices path musi być listą indeksów elementów podrzędnych @msg no object at path {shown} in the symbol's art brak obiektu w ścieżce {shown} w grafice symbolu @msg give fill, stroke, opacity and/or blend podaj fill, stroke, opacity i/lub blend diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 73cf2c8ee..af16149fe 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6324,6 +6324,7 @@ breakText Glyphs Glifos Reset Symbol Instance Redefinir instância de símbolo @msg type must be static or dynamic, not {v} type deve ser static ou dynamic, não {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` é um símbolo estático: defina primeiro o tipo de símbolo como Dinâmico +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change De um caminho, caminho composto ou texto selecionado em uma instância de símbolo só é possível alterar a cor sólida do preenchimento e do traçado @msg path must be a list of child indices path deve ser uma lista de índices de filhos @msg no object at path {shown} in the symbol's art nenhum objeto no caminho {shown} na arte do símbolo @msg give fill, stroke, opacity and/or blend informe fill, stroke, opacity e/ou blend diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9b462f1b9..2ff6c5873 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6383,6 +6383,7 @@ breakText Glyphs Глифы Reset Symbol Instance Сбросить экземпляр символа @msg type must be static or dynamic, not {v} type должен быть static или dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статический символ: сначала задайте для него тип символа «Динамический» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контура, составного контура или текста, выделенного в экземпляре символа, можно изменить только сплошной цвет заливки и обводки @msg path must be a list of child indices path должен быть списком индексов дочерних объектов @msg no object at path {shown} in the symbol's art в графике символа нет объекта по пути {shown} @msg give fill, stroke, opacity and/or blend укажите fill, stroke, opacity и/или blend diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 44dfd2563..ea0550be3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6458,6 +6458,7 @@ breakText Glyphs Glyfer Reset Symbol Instance Återställ symbolinstans @msg type must be static or dynamic, not {v} type måste vara static eller dynamic, inte {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` är en statisk symbol: ställ först in symboltypen på Dynamisk +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change För en bana, sammansatt bana eller text som är markerad i en symbolinstans kan bara enfärgad fyllning och linje ändras @msg path must be a list of child indices path måste vara en lista med underordnade index @msg no object at path {shown} in the symbol's art inget objekt vid sökvägen {shown} i symbolens bild @msg give fill, stroke, opacity and/or blend ange fill, stroke, opacity och/eller blend diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6ce1191c8..cf5eea934 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6363,6 +6363,7 @@ breakText Glyphs Glifler Reset Symbol Instance Sembol Örneğini Sıfırla @msg type must be static or dynamic, not {v} type static veya dynamic olmalıdır, {v} değil @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statik bir semboldür: önce Sembol Türünü Dinamik olarak ayarlayın +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Bir sembol örneğinde seçilen yolun, bileşik yolun veya yazının yalnızca dolgu ve konturunun düz rengi değiştirilebilir @msg path must be a list of child indices path alt öğe dizinlerinden oluşan bir liste olmalıdır @msg no object at path {shown} in the symbol's art sembolün çiziminde {shown} yolunda nesne yok @msg give fill, stroke, opacity and/or blend fill, stroke, opacity ve/veya blend verin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 81d168096..b8d3c44c0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6006,6 +6006,7 @@ breakText Glyphs Гліфи Reset Symbol Instance Скинути екземпляр символу @msg type must be static or dynamic, not {v} type має бути static або dynamic, а не {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статичний символ: спершу встановіть для нього тип символу «Динамічний» +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change У контуру, складеного контуру або тексту, виділеного в екземплярі символу, можна змінити лише суцільний колір заливки й обведення @msg path must be a list of child indices path має бути списком індексів дочірніх об’єктів @msg no object at path {shown} in the symbol's art у графіці символу немає об’єкта за шляхом {shown} @msg give fill, stroke, opacity and/or blend вкажіть fill, stroke, opacity та/або blend diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3b1169439..7a736a1b4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6007,6 +6007,7 @@ breakText Glyphs Glyph Reset Symbol Instance Đặt lại đối tượng ký hiệu @msg type must be static or dynamic, not {v} type phải là static hoặc dynamic, không phải {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` là ký hiệu tĩnh: trước tiên hãy đặt Kiểu ký hiệu thành Động +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change Chỉ có thể thay đổi màu đặc của màu tô và nét của đường dẫn, đường dẫn phức hoặc chữ được chọn trong một đối tượng ký hiệu @msg path must be a list of child indices path phải là danh sách chỉ số con @msg no object at path {shown} in the symbol's art không có đối tượng tại đường dẫn {shown} trong hình của ký hiệu @msg give fill, stroke, opacity and/or blend hãy cung cấp fill, stroke, opacity và/hoặc blend diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9fa2f182..347f1a47a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6247,6 +6247,7 @@ breakText Glyphs 字形 Reset Symbol Instance 重置符号实例 @msg type must be static or dynamic, not {v} type 必须是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是静态符号:请先将其符号类型设为动态 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符号实例中选中的路径、复合路径或文字只能更改填色和描边的纯色 @msg path must be a list of child indices path 必须是子项索引列表 @msg no object at path {shown} in the symbol's art 符号图稿中路径 {shown} 处没有对象 @msg give fill, stroke, opacity and/or blend 请提供 fill、stroke、opacity 和/或 blend diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6a34a843a..663a94c06 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6044,6 +6044,7 @@ breakText Glyphs 字符 Reset Symbol Instance 重設符號實體 @msg type must be static or dynamic, not {v} type 必須是 static 或 dynamic,而不是 {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是靜態符號:請先將其符號類型設為動態 +@msg Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change 在符號實體中選取的路徑、複合路徑或文字只能變更填色和筆畫的純色 @msg path must be a list of child indices path 必須是子項索引清單 @msg no object at path {shown} in the symbol's art 符號圖稿中路徑 {shown} 處沒有物件 @msg give fill, stroke, opacity and/or blend 請提供 fill、stroke、opacity 和/或 blend diff --git a/docs/gaps.md b/docs/gaps.md index a73d82c73..8369b2573 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -624,9 +624,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall as a `.vectorcraft` library into the user library folder (listed under User Defined) or a file (`brush.libraries`, `brush.addFromLibrary`, `brush.saveLibrary`, `brush.loadLibrary`). - **Missing:** the Art brush's Overlap; more built-in brush, symbol and graphic-style libraries (original, - generated in code, never Adobe's); dynamic symbols' on-canvas editing (Direct Selection of an object inside a - dynamic instance, with the Fill/Stroke controls and Appearance panel then setting its change) and their `.ai` - read and write; Global Edit of groups holding type or + generated in code, never Adobe's); dynamic symbols' on-canvas editing beyond a solid fill and stroke on a path, + compound path or type object inside a dynamic instance (Direct Selection selects it, and the Fill/Stroke controls set + its change) and their `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 14–24 h. diff --git a/docs/mcp.md b/docs/mcp.md index 284a5e887..3e43b8e60 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1790,7 +1790,35 @@ already walks Image Trace. Short agent entry points: root; `[1, 0]` is the first object of the art's second member when that member is a group). `fill` and `stroke` take a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`), `opacity` is 0..100 and `blend` a blend mode name; `null` gives one back to the symbol. It is refused for a static symbol and for - a path that names no object. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties + a path that names no object. On the canvas (VectorCraft's own rule), a Direct Selection click on an instance of a + dynamic symbol selects the path, compound path or type object of its art under the pointer and outlines it + (`symbol.instance.selectPart {id, path}`; Alt or Shift selects the instance whole). While it is selected, + `paint.setFill` and `paint.setStroke` with a solid color or none, `paint.invert` and `paint.complement` on a solid + color, all without `ids`, `id` or `item`, and the Eyedropper's `appearance.copyFrom` with a solid fill and stroke + change that object's fill or stroke for this instance only, each as one undo step. A painted stroke on type without a + weight gets 1 pt. A gradient or pattern for the object is refused with a status message, and so are `paint.invert` and + `paint.complement` on type whose characters differ in color, Eyedropper options that pick up neither color, an + Eyedropper source that is not a path, compound path or type (a symbol instance or a group, for which the paint defaults + stay as they are too), and a click + or `symbol.instance.selectPart` on any other object of the art (a group, image, blend or nested instance among them). + The commands that change the selected objects without `ids` are refused the same way and change nothing, among them + opacity and blending mode, moving and transforming, `path.moveAnchors`, `file.place` with `replace`, Puppet Warp, Delete + and Cut, Group, Hide, Lock, Arrange, Expand, + Recolor Artwork, plug-in filters, effects, the gradient commands, `gradient.selectStop`, the Appearance panel's rows and + `symbol.instance.setAppearance`. A Cmd-drag that lends the Selection or Group Selection tool selects the instance whole + first and moves it. `swatch.unused` and `recolor.colors` count the fills and strokes an instance keeps for objects of its + art. Commands that change nothing in the document, such as `attributes.info`, `css.selection`, `css.export`, + `effect.list` and `object.expand.info`, read the instance. These commands do not check for a selected object and use the instance: + `edit.copy` copies the instance whole; `document.exportSelection`, `document.export` with `selectionOnly` and + selection-mode `document.exportForScreens` export it whole; `artboard.fitToSelection` fits the artboard to the whole instance; + `object.liquify` without `ids` takes the instance as its target and, since it reshapes paths only, leaves it as it is + and lists it in `skipped` when the brush reaches it; `object.isolate` without `id` isolates the instance and clears the + selection; `library.add` without `ids` adds a copy of the whole instance as a `graphic`, and its fill and stroke kinds + read the instance rather than the selected object; `select.save` saves the instance, and `select.recall` then selects it + whole. Direct Selection passes over hidden, locked and template objects of the art, and + `symbol.instance.selectPart` refuses a path to one or to an object inside one, and an instance that is hidden or + locked or sits in a hidden or locked group or layer. Choosing another selection tool selects the instance whole again. + `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c388ee2fd..49b266520 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -721,6 +721,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. + - **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's solid color fill or stroke for this instance only, each as one undo step, and the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. A gradient or pattern for the object, an Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (opacity, moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient commands, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs`, `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`. - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. @@ -1754,7 +1755,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: on-canvas editing beyond a solid fill and stroke on a path, compound path or type object inside a dynamic instance; reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b7b2250c5..fca9b2354 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -385,7 +385,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | -| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; on-canvas Direct Selection of a part inside an instance (with the Fill/Stroke controls then setting its change) remains | +| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains |