From bc9ea516f5eabcd148e6f8a0c93f7bd0589d25bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 17:05:31 -0800 Subject: [PATCH 1/3] Dimension tool: Expand keeps a dimension as plain art after it moves Object > Expand left a dimension's recipe on its group, so the next move redrew the live dimension and discarded the expanded art. Expand now ends the live link of a dimension whose art it changes, in the same undo step. Moving or scaling the group leaves its art as it is, the art no longer follows what it measured, and undo gives the live dimension back. Expand Appearance, Create Outlines and Outline Stroke had the same fault and now do the same, also when they reach only a part of a dimension. A dimension whose art a command leaves as it was stays live. A dimension selected with the art it measures becomes a plain group when Expand, Expand Appearance or Outline Stroke replaces only that art. --- crates/engine/src/cmd/dimension.rs | 58 +++++ crates/engine/src/cmd/effectcmd.rs | 7 +- crates/engine/src/cmd/expand.rs | 32 ++- crates/engine/src/cmd/pathops.rs | 7 +- crates/engine/src/cmd/typecmd.rs | 4 +- crates/engine/tests/it/dimensions.rs | 342 +++++++++++++++++++++++++++ docs/dimensions.md | 10 +- docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 9 files changed, 455 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index f63e0262b..f39aba2b1 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -195,6 +195,64 @@ fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> { Ok(()) } +/// A dimension whose live link [`end_links`] ended, as it was before. +pub(crate) struct EndedLink { + id: NodeId, + recipe: Dimension, + art: Vec>, +} + +/// The dimensions under `roots` or holding one of them. A dimension can be listed more than once. +pub(crate) fn reached(doc: &Document, roots: &[NodeId]) -> Vec { + let mut ids = Vec::new(); + for n in roots.iter().filter_map(|r| doc.node(*r)) { + n.walk(&mut |c| { + if c.extras().dimension.is_some() { + ids.push(c.id); + } + }); + if doc.dimensions { + let holders = doc.ancestry(n.id).unwrap_or_default(); + ids.extend(holders.into_iter().filter(|id| doc.node(*id).is_some_and(|h| h.extras().dimension.is_some()))); + } + } + ids +} + +impl EndedLink { + /// Is the art this dimension measures, changed from `before` to `after`, still art it can link to? + pub(crate) fn source_kept(&self, before: &Document, after: &Document) -> bool { + !self.recipe.source.as_ref().is_some_and(|s| s.topology_changed(before, after)) && self.recipe.clone().resolve(after).is_ok() + } +} + +/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it +/// measured. A command that replaces art calls this before it changes anything, since replacing +/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`]. +pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec { + let mut ended = Vec::new(); + for id in reached(doc, roots) { + let Some(n) = doc.node_mut(id) else { continue }; + let art = n.children().cloned().unwrap_or_default(); + if let Some(recipe) = n.extras_mut().dimension.take() { + ended.push(EndedLink { id, recipe: *recipe, art }); + } + } + ended +} + +/// The dimensions in `ended` whose art is as it was are live again. +pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec) { + for e in ended { + if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) + && let Some(n) = doc.node_mut(e.id) + { + n.extras_mut().dimension = Some(Box::new(e.recipe)); + doc.dimensions = true; + } + } +} + /// Called inside every edit, so dimensions and their undo history stay in step with the art they /// measure. Returns at once in documents without dimensions. pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index a823ee161..b8f614858 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or whose measured art it replaces, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -828,12 +828,17 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let failed = vectorcraft_render::decode_failures(); let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { + let before = d.clone(); + let mut ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut out = vec![]; let threaded = super::threads::frames(d); for id in &roots { expand_node(d, *id, &brushes, &mut out); } + // A dimension whose source was replaced stays plain: its link no longer fits that art. + ended.retain(|e| e.source_kept(&before, d)); + super::dimension::restore_unchanged(d, ended); // Outlined frames of threaded text leave their threads with the text they show; type that // stays type under a new id takes its frame's place. super::threads::leave_threads(d, &threaded); diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index cead1d4d5..4c2680120 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -4,7 +4,8 @@ //! as Break Link to Symbol places it. A linear or radial gradient fill becomes a //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip -//! group shaped like the object. Create Gradient Mesh shares the sampling. +//! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art +//! Expand changes becomes a plain group of that art. use std::collections::BTreeSet; use std::sync::Arc; @@ -19,6 +20,7 @@ use vectorcraft_doc::pattern::{PatternDef, place_art}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; +use super::dimension::EndedLink; use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; @@ -41,7 +43,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), any option that changes a dimension's art makes the dimension a plain group of that art, fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -239,8 +241,12 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let stroke = bool_or(p, "stroke", true); let to = GradientTo::parse(p)?; let from = s.doc()?.history.undo.len(); - let rev0 = s.doc()?.revision; let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + // Before anything expands: outlining the art a dimension measures would remove the dimension. + let before = s.doc()?.doc.clone(); + let mut ended = vec![]; + end_dimension_links(s, &mut ended)?; + let rev0 = s.doc()?.revision; // Placed documents first: their art (type, strokes, gradients) expands below. if object && e.placed { let roots = selected_roots(s)?; @@ -286,6 +292,8 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?; } + // The dimensions in the art those steps made. + end_dimension_links(s, &mut ended)?; if object && e.effects { // Effects that Expand Appearance can't expand are left as they are. When it refuses to // leave a process color on a grayscale image with transparency, Expand fails as a whole. @@ -331,10 +339,28 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { if s.doc()?.revision == rev0 { return Err(EngineError::Other("Expand: nothing to expand".into())); } + if !ended.is_empty() { + // A dimension whose source Expand replaced stays plain: its link no longer fits that art. + let after = s.doc()?.doc.clone(); + ended.retain(|e| e.source_kept(&before, &after)); + s.edit("Expand", |d, _| { + super::dimension::restore_unchanged(d, std::mem::take(&mut ended)); + Ok(()) + })?; + } squash(s, from, "Expand"); Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::>() })) } +/// Ends the live link of each dimension under or holding the selection, adding it to `ended`. +fn end_dimension_links(s: &mut Session, ended: &mut Vec) -> Result<()> { + let roots = selected_roots(s)?; + if !super::dimension::reached(&s.doc()?.doc, &roots).is_empty() { + ended.extend(s.edit("Expand", |d, _| Ok(super::dimension::end_links(d, &roots)))?); + } + Ok(()) +} + fn drop_live(n: &mut Node) { if let NodeKind::Path { live, .. } = &mut n.kind { *live = None; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5fd0f5443..85e4dc8c1 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Outline Stroke", ["Object", "Path"], None, - "{} replace stroked paths with filled outlines → {ids}", + "{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}", has_selection, outline_stroke ), @@ -980,6 +980,8 @@ pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(& fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { + let before = d.clone(); + let mut ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut new_sel = vec![]; let mut changed = 0; @@ -988,6 +990,9 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { new_sel.push(id); changed += n; } + // A dimension whose source was outlined stays plain: its link no longer fits that art. + ended.retain(|e| e.source_kept(&before, d)); + super::dimension::restore_unchanged(d, ended); if changed == 0 { return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into())); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 519d282e8..e127d4e69 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows → {ids}", + "{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}", has_selection, create_outlines ), @@ -554,6 +554,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if texts.is_empty() { return Err(EngineError::Other("Create Outlines: select text objects".into())); } + let ended = super::dimension::end_links(d, &roots); let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); let mut out = vec![]; let threaded = super::threads::frames(d); @@ -648,6 +649,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { new_sel.push(gid); } } + super::dimension::restore_unchanged(d, ended); // Outlined frames of threaded text leave their threads with the text they show. super::threads::leave_threads(d, &threaded); sel.set(new_sel); diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 65b2f423c..16deb3d24 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -280,6 +280,348 @@ fn a_moved_linked_dimension_stays_where_it_was_dropped() { assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "moved together"); } +/// How many nodes a dimension's art holds, how many of them are type, and whether it has a recipe. +fn art(s: &Session, id: u64) -> (usize, usize, bool) { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap(); + let (mut nodes, mut labels) = (0, 0); + n.walk(&mut |c| { + nodes += 1; + labels += usize::from(matches!(c.kind, NodeKind::Text(_))); + }); + (nodes, labels, n.extras().dimension.is_some()) +} +fn bounds(s: &Session, id: u64) -> [f64; 4] { + let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); + [b.x0, b.y0, b.x1, b.y1] +} +/// The art of `id`, which holds no type and no recipe, follows a move, a scale, undo and redo as +/// plain art does. +fn stays_plain_art(s: &mut Session, id: u64) { + let plain = art(s, id); + assert_eq!(plain.1, 0, "no type left: {plain:?}"); + let at = bounds(s, id); + run(s, "select.set", json!({"ids":[id]})); + run(s, "object.move", json!({"dx":30,"dy":-20})); + assert_eq!(art(s, id), plain, "after a move"); + let moved = bounds(s, id); + for (k, (a, b)) in at.iter().zip(moved).enumerate() { + let d = if k % 2 == 0 { 30.0 } else { -20.0 }; + assert!((b - a - d).abs() < 1e-6, "{at:?} moved to {moved:?}"); + } + run(s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(art(s, id), plain, "after a scale"); + let scaled = bounds(s, id); + assert!(((scaled[2] - scaled[0]) - 2.0 * (at[2] - at[0])).abs() < 1e-6, "{at:?} scaled to {scaled:?}"); + run(s, "edit.undo", json!({})); + assert_eq!(art(s, id), plain, "after undoing the scale"); + run(s, "edit.redo", json!({})); + assert_eq!(art(s, id), plain, "after redoing the scale"); + run(s, "edit.undo", json!({})); + run(s, "edit.undo", json!({})); + assert_eq!(art(s, id), plain); + assert_eq!(bounds(s, id), at); + assert!(!plain.2, "no recipe left"); +} + +/// Object › Expand turns a dimension into plain art for good: it no longer follows the art it +/// measured, and one undo gives the live dimension back. +#[test] +fn an_expanded_dimension_stays_plain_art() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let other = dim(&mut s, line); + let live = art(&s, id); + assert_eq!((live.1, live.2), (1, true)); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.expand", json!({})); + stays_plain_art(&mut s, id); + let plain = art(&s, id); + let at = bounds(&s, id); + assert_eq!(art(&s, other), live, "the dimension left out of the selection stays live"); + // The art it measured no longer moves it, and deleting that art leaves it. + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, other), "20.00 in"); + assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); + run(&mut s, "edit.clear", json!({})); + assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); + assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none()); + // Saved and opened again, it is still plain art. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let mut reopened = Session::new(); + reopened.add_document(doc, None); + stays_plain_art(&mut reopened, id); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); + assert_eq!(text(&s, id), "10.00 in"); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, id), "20.00 in", "linked again"); +} + +/// Expand's options: any one that changes a dimension's art ends its link, and an Expand that +/// changes nothing leaves it live. +#[test] +fn expand_options_that_change_a_dimension_end_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + assert!(s.execute("object.expand", &json!({"object":false,"stroke":false})).is_err(), "no fills to expand"); + assert_eq!(art(&s, id), live); + assert!(s.doc().unwrap().doc.dimensions); + // Stroke alone leaves type as it is, so a stroked label on its own does not end the link. + let label = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id.0 + }; + run(&mut s, "paint.setStroke", json!({"ids":[label],"color":"#ff0000"})); + run(&mut s, "select.set", json!({"ids":[label]})); + assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err(), "no paths to outline"); + assert!(art(&s, id).2, "still live"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live); + run(&mut s, "select.set", json!({"ids":[id]})); + // Stroke alone: the label stays type, in art that no longer redraws. + run(&mut s, "object.expand", json!({"object":false,"fill":false})); + let plain = art(&s, id); + assert_eq!(plain.1, 1, "{plain:?}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live); + // Object alone: the lines stay stroked paths, in art that no longer redraws. + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.expand", json!({"stroke":false,"fill":false})); + let plain = art(&s, id); + assert_eq!(plain.1, 0, "{plain:?}"); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); +} + +/// An Expand that ends up changing no art fails and leaves the dimension live: Outline Stroke skips +/// guides, here the dimension's lines. +#[test] +fn an_expand_that_changes_no_art_leaves_a_dimension_live() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let lines: Vec = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect() + }; + run(&mut s, "select.set", json!({"ids":lines})); + run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err()); + assert_eq!(art(&s, id), live); + assert!(live.2); + // A stroked path selected with it expands, and the dimension's art stays live. + let other = run(&mut s, "shape.rectangle", json!({"x":300,"y":300,"width":50,"height":50}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[other],"color":"#000000"})); + let moved_with = s.doc().unwrap().doc.node_count(); + run(&mut s, "select.set", json!({"ids":[id, other]})); + run(&mut s, "object.expand", json!({"object":false,"fill":false})); + assert_ne!(s.doc().unwrap().doc.node_count(), moved_with, "the rectangle's stroke is outlined"); + assert_eq!(art(&s, id), live); +} + +/// A dimension selected with the art it measures, or held by a selected group, expands the same +/// way: outlining that art does not remove it. +#[test] +fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + run(&mut s, "select.set", json!({"ids":[line, id]})); + run(&mut s, "object.expand", json!({})); + stays_plain_art(&mut s, id); + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.group", json!({})); + run(&mut s, "object.expand", json!({})); + stays_plain_art(&mut s, id); +} + +/// Expand replacing only the art a selected dimension measures leaves the dimension's art as it +/// is, without a link to the art that went. +#[test] +fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { + for circle in [false, true] { + let mut s = session(); + let shape = json!({"x":100,"y":100,"width":72,"height":72}); + let art_id = run(&mut s, if circle { "shape.ellipse" } else { "shape.rectangle" }, shape)["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"ids":[art_id],"gradient":{"kind":"linear"}})); + let id = if circle { + run(&mut s, "dimension.create", json!({"mode":"radius","source":{"kind":"circle","node":art_id}}))["id"].as_u64().unwrap() + } else { + dim(&mut s, art_id) + }; + let (nodes, labels, _) = art(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({"object":false,"stroke":false})); + assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); + } +} + +/// Outline Stroke and Expand Appearance replacing the art a selected dimension measures leave the +/// dimension as a plain group, whether or not they change its own art. +#[test] +fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { + for command in ["object.path.outlineStroke", "effect.expandAppearance"] { + for whole in [true, false] { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "appearance.addStroke", json!({})); + let id = dim(&mut s, art_id); + let lines: Vec = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect() + }; + run(&mut s, "select.set", json!({"ids":lines})); + run(&mut s, "view.guides.make", json!({})); + let picked = if whole { id } else { lines[0] }; + run(&mut s, "select.set", json!({"ids":[art_id, picked]})); + run(&mut s, command, json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(id)).is_some(), "{command} removed the dimension, whole: {whole}"); + let plain = art(&s, id); + assert!(!plain.2, "{command}: no recipe left, whole: {whole}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain, "{command}, whole: {whole}"); + } + } +} + +/// A part of a dimension expanded on its own ends the link too, so the move does not undo it. +#[test] +fn expanding_a_part_of_a_dimension_ends_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let part = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap()[0].id; + run(&mut s, "select.set", json!({"ids":[part]})); + run(&mut s, "object.expand", json!({})); + let plain = art(&s, id); + assert_eq!(plain.1, 1, "{plain:?}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); +} + +/// The instance of a symbol whose art is a dimension expands to art that no longer redraws. +#[test] +fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() { + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[100,100],"b":[172,100],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[id]})); + let instance = run(&mut s, "symbol.new", json!({"name":"Measure"}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[instance]})); + run(&mut s, "object.expand", json!({})); + let root = s.doc().unwrap().selection.objects[0].0; + let mut recipes = 0; + s.doc().unwrap().doc.node(NodeId(root)).unwrap().walk(&mut |c| recipes += usize::from(c.extras().dimension.is_some())); + assert_eq!(recipes, 0); + stays_plain_art(&mut s, root); +} + +/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step. +#[test] +fn outlining_a_dimension_or_its_label_ends_its_link() { + for label_alone in [false, true] { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + let label = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id + }; + let target = if label_alone { label.0 } else { id }; + run(&mut s, "select.set", json!({"ids":[target]})); + run(&mut s, "type.createOutlines", json!({})); + stays_plain_art(&mut s, id); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); + assert_eq!(text(&s, id), "10.00 in"); + } +} + +/// Outline Stroke on a dimension ends its link: the label stays type and the art does not redraw. +#[test] +fn outlining_a_dimensions_strokes_ends_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.path.outlineStroke", json!({})); + let plain = art(&s, id); + assert_eq!(plain.1, 1, "{plain:?}"); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); +} + +/// Expand Appearance, which the menus offer for a dimension in place of Expand, ends its link too. +#[test] +fn expanding_a_dimensions_appearance_ends_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], true); + run(&mut s, "effect.expandAppearance", json!({})); + stays_plain_art(&mut s, id); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); + assert_eq!(text(&s, id), "10.00 in"); +} + +/// Ungroup takes the group that holds the recipe away, so the parts stay ordinary art. +#[test] +fn an_ungrouped_dimension_stays_ordinary_art() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let (nodes, ..) = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.ungroup", json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none()); + let parts = s.doc().unwrap().selection.objects.clone(); + assert_eq!(parts.len(), nodes - 1); + let boxes = |s: &Session| parts.iter().map(|p| s.doc().unwrap().doc.node(*p).unwrap().geometric_bounds().unwrap()).collect::>(); + let at = boxes(&s); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + for (a, b) in at.iter().zip(boxes(&s)) { + assert!((b.x0 - a.x0 - 30.0).abs() < 1e-6 && (b.y0 - a.y0).abs() < 1e-6, "{a:?} moved to {b:?}"); + } + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert!(!s.doc().unwrap().doc.dimensions); +} + #[test] fn dimension_options_are_persistent_and_invalid_updates_report_errors() { let mut s = session(); diff --git a/docs/dimensions.md b/docs/dimensions.md index b8ece996a..129800319 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -36,8 +36,14 @@ changes); moved together with its art, it keeps its distance. Native `.vectorcraft` files keep the recipes and links. SVG, PDF and bitmap output contain ordinary paths and text, so exported dimensions look the same. Ungrouping a dimension leaves its -paths and text as ordinary art. Pasted dimensions keep their geometry and drop their links, so -they never bind to unrelated art in another document. +paths and text as ordinary art. Object › Expand, Object › Expand Appearance, Type › Create Outlines +and Object › Path › Outline Stroke turn a dimension whose art they change into a plain group of that +art, also when they reach only a part of it: moving or scaling the group does not redraw it, it does +not follow the art it measured, and undoing the command gives the live dimension back. A dimension +selected with the art it measures also becomes a plain group when Object › Expand, Object › Expand +Appearance or Object › Path › Outline Stroke replaces only that art. Pasted +dimensions keep their geometry and drop their links, so they never bind to unrelated art in another +document. ## Commands diff --git a/docs/mcp.md b/docs/mcp.md index 8ddd96d57..d3fe607e7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2972,6 +2972,8 @@ for which Object → Expand… is dimmed in the menus; `symbolArt: {fill, stroke holds symbol instances that `object` expands, whether only their art holds it, which Fill and Stroke reach only with `object` on), and `object.mesh.create` on a gradient-filled object colours the mesh points as the gradient paints them, with the colors Object › Expand gives them. +`object.expand`, `effect.expandAppearance`, `type.createOutlines` and `object.path.outlineStroke` turn a dimension whose +art they change into a plain group of that art, in the same undo step (see [dimensions.md](dimensions.md)). ```json {"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ae62e897..070da6ecd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -708,6 +708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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`. - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). + - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes 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. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. From 9e0e8993b992cb7894953de83d3cef2734cedcb7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:24:18 -0800 Subject: [PATCH 2/3] Dimension tool: state the Expand rule after object.expand's parameter list The sentence inside the braces made every_description_parses fail. The object.expand and effect.expandAppearance texts now also limit the replaced-art case to a dimension selected with that art. --- crates/engine/src/cmd/effectcmd.rs | 2 +- crates/engine/src/cmd/expand.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index b8f614858..b67d5bb97 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or whose measured art it replaces, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 4c2680120..d67b89a97 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), any option that changes a dimension's art makes the dimension a plain group of that art, fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}", has_selection, expand ), From 09aa816394c9615297e1d8cd09dccf7c65b73a95 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:56:10 -0800 Subject: [PATCH 3/3] Docs: dimensions state when the menus dim Expand and when a dimension stays live --- docs/dimensions.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/dimensions.md b/docs/dimensions.md index 129800319..e55e698f2 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -39,9 +39,13 @@ ordinary paths and text, so exported dimensions look the same. Ungrouping a dime paths and text as ordinary art. Object › Expand, Object › Expand Appearance, Type › Create Outlines and Object › Path › Outline Stroke turn a dimension whose art they change into a plain group of that art, also when they reach only a part of it: moving or scaling the group does not redraw it, it does -not follow the art it measured, and undoing the command gives the live dimension back. A dimension -selected with the art it measures also becomes a plain group when Object › Expand, Object › Expand -Appearance or Object › Path › Outline Stroke replaces only that art. Pasted +not follow the art it measured, and undoing the command gives the live dimension back. The menus dim +Object › Expand… while a whole dimension is selected, because its label has appearance items; +Object › Expand Appearance and the `object.expand` command still reach it. A dimension selected with +the art it measures also becomes a plain group when Object › Expand, Object › Expand Appearance or +Object › Path › Outline Stroke changes only that art and the art no longer fits the dimension's link, +for example because its number of anchors changed. When the art keeps its id and anchor count and the +dimension's own art does not change, the dimension stays live and its label shows the new length. Pasted dimensions keep their geometry and drop their links, so they never bind to unrelated art in another document.