Merge pull request #6559 from dotCooCoo/fix/dimension-expand-keeps-live

Dimension tool: a dimension selected with its art stays live through Expand and Outline Stroke
This commit is contained in:
BFlat
2026-11-11 17:16:39 +07:00
committed by GitHub
10 changed files with 510 additions and 84 deletions
+148 -20
View File
@@ -272,9 +272,9 @@ impl EndedLink {
}
/// 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 otherwise leave the dimension unlinked, and then
/// [`restore_unchanged`].
/// measured. A command that replaces art calls this before it changes anything and
/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a
/// plain group.
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
let mut ended = Vec::new();
for id in reached(doc, roots) {
@@ -287,18 +287,156 @@ pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink>
ended
}
/// The dimensions in `ended` whose art is as it was are live again.
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
/// The dimensions in `ended` whose own art is as it was are live again. One whose measured art the
/// command changed from `before` so that the art no longer fits its link stays where it was, no
/// longer linked, as [`refresh`] leaves a dimension whose art was deleted.
pub(crate) fn restore_unchanged(before: &Document, doc: &mut Document, ended: Vec<EndedLink>) {
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));
if !doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) {
continue;
}
let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) };
if let Some(n) = doc.node_mut(e.id) {
n.extras_mut().dimension = Some(Box::new(recipe));
doc.dimensions = true;
}
}
}
/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when
/// the moved dimension is valid.
fn unlinked(old: &Dimension) -> Dimension {
let mut unlinked = old.clone();
unlinked.source = None;
unlinked.moved = None;
if let Some(v) = old.moved {
let mut shifted = unlinked.clone();
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
if shifted.validate().is_ok() {
unlinked = shifted;
}
}
unlinked
}
/// The whole dimensions under `roots` that are selected together with art they measure (art under
/// `roots`), each one a root or held by groups without fills, strokes or effects of their own.
/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their
/// lines and label as they are. A group with an appearance of its own changes everything it holds
/// when it expands, its dimensions included.
pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
if !doc.dimensions {
return Vec::new();
}
let mut found = Vec::new();
for root in roots {
let Some(n) = doc.node(*root) else { continue };
n.walk(&mut |c| {
if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) {
found.push((*root, c.id, source.nodes()));
}
});
}
let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a)));
found
.into_iter()
.filter(|(root, id, _)| {
let Some(ancestry) = doc.ancestry(*id) else { return false };
let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len());
ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group))
})
.filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m)))
.map(|(_, id, _)| id)
.collect()
}
/// A group or layer without fills, strokes or effects of its own.
fn plain_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty()
}
/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or
/// its parts (with their places in the asset list) and its parts' variable bindings.
pub(crate) struct Lifted {
parent: Option<NodeId>,
index: usize,
node: Arc<Node>,
assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>,
bindings: Vec<(NodeId, std::collections::BTreeSet<String>)>,
}
impl Lifted {
pub(crate) fn id(&self) -> NodeId {
self.node.id
}
}
/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces
/// art does not reach them; [`put_back`] puts them back.
pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec<Lifted> {
let mut lifted = Vec::new();
for id in with_their_art(doc, roots) {
let Some((parent, index, _)) = doc.position(id) else { continue };
let mut parts = std::collections::BTreeSet::new();
if let Some(n) = doc.node(id) {
n.walk(&mut |c| {
parts.insert(c.id);
});
}
let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect();
let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect();
if let Ok(node) = doc.remove(id) {
lifted.push(Lifted { parent, index, node, assets, bindings });
}
}
lifted
}
/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in
/// the export assets that listed them and their variable bindings back: edits made while it was out
/// let go of them. A dimension whose group is gone goes on top of the first layer.
pub(crate) fn put_back(doc: &mut Document, lifted: Vec<Lifted>) {
for l in lifted.into_iter().rev() {
let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) };
let Ok(ch) = doc.children_mut(parent) else { continue };
let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() };
ch.insert(i, l.node);
doc.dimensions = true;
for (at, saved) in l.assets {
let there: Vec<NodeId> = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect();
if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) {
for (k, n) in there.iter().enumerate() {
if !a.nodes.contains(n) {
let k = k.min(a.nodes.len());
a.nodes.insert(k, *n);
}
}
} else if !there.is_empty() {
let at = at.min(doc.assets.len());
doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved });
}
}
for (n, b) in l.bindings {
doc.variables.bindings.entry(n).or_insert(b);
}
}
}
/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves
/// them alone would change: `doc` itself when there are none.
pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> {
let ids = with_their_art(doc, roots);
if ids.is_empty() {
return std::borrow::Cow::Borrowed(doc);
}
let mut view = doc.clone();
for id in ids {
// A dimension already gone (inside another one removed) needs nothing more.
let _ = view.remove(id);
}
std::borrow::Cow::Owned(view)
}
/// 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<bool> {
@@ -320,17 +458,7 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
// The art it measured was deleted, or rebuilt with other anchors, or no longer fits
// (an ellipse that was a circle): it stays where it was, no longer linked, in this
// same undo step, rather than attaching to a different edge.
let mut unlinked = old.clone();
unlinked.source = None;
unlinked.moved = None;
if let Some(v) = moved {
let mut shifted = unlinked.clone();
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
if shifted.validate().is_ok() {
unlinked = shifted;
}
}
if replace(doc, id, unlinked).is_err()
if replace(doc, id, unlinked(&old)).is_err()
&& let Some(n) = doc.node_mut(id)
{
// Its recipe cannot be drawn again: its art stays as it is, as a plain group.
+25 -10
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 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. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. 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 becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. 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
),
@@ -674,11 +674,19 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
/// fills or strokes of its own?
pub(crate) fn expandable(n: &Node) -> bool {
expandable_except(n, &[])
}
/// [`expandable`], leaving out the objects `skip` lists and what they hold.
pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
if skip.contains(&n.id) {
return false;
}
match &n.kind {
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
}
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
@@ -688,9 +696,11 @@ pub(crate) fn expandable(n: &Node) -> bool {
fn can_expand(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let d = &s.doc().map_err(|e| e.to_string())?.doc;
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
if ids.iter().filter_map(|id| d.node(*id)).any(expandable) {
let d = &s.doc().map_err(|e| e.to_string())?.doc;
// A dimension selected with the art it measures is left as it is.
let kept = super::dimension::with_their_art(d, &ids);
if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) {
Ok(())
} else {
Err("select (or target) objects whose appearance isn't basic".into())
@@ -975,7 +985,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
// geometry it hasn't changed.
if vectorcraft_plugins::safe_mode::active() {
let d = &s.doc()?.doc;
// A dimension selected with the art it measures is left as it is.
let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots);
let mut seen = std::collections::HashSet::new();
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
@@ -985,20 +996,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
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);
// A dimension selected with the art it measures keeps its own lines and label: it is out
// of the document while the art expands.
let lifted = super::dimension::lift(d, &roots);
let kept = !lifted.is_empty();
let 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);
super::dimension::put_back(d, lifted);
// A dimension whose source was replaced stays where it was, no longer linked.
super::dimension::restore_unchanged(&before, 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);
if out.is_empty() {
if out.is_empty() || (kept && d.layers == before.layers) {
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
}
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?;
+35 -11
View File
@@ -5,7 +5,8 @@
//! 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. A dimension whose art
//! Expand changes becomes a plain group of that art.
//! Expand changes becomes a plain group of that art, except one selected with art it measures,
//! which Expand leaves as it is.
use std::collections::BTreeSet;
use std::sync::Arc;
@@ -43,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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; 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}",
"{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 becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
has_selection,
expand
),
@@ -55,7 +56,8 @@ pub fn specs() -> Vec<CommandSpec> {
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
has_selection,
|s, _| {
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
let roots = selected_roots(s)?;
let e = Expandable::of(&super::dimension::without_kept(&s.doc()?.doc, &roots), &roots);
let mut info =
json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)});
if e.symbols {
@@ -74,7 +76,9 @@ pub fn specs() -> Vec<CommandSpec> {
/// (#3089); `object.expand` itself still bakes them.
pub fn has_appearance(s: &Session) -> bool {
let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false };
roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable)
// A dimension selected with the art it measures is left as it is.
let kept = super::dimension::with_their_art(&st.doc, &roots);
roots.iter().filter_map(|r| st.doc.node(*r)).any(|n| super::effectcmd::expandable_except(n, &kept))
}
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
@@ -241,9 +245,18 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = switch_or(p, "stroke", true, C)?;
let to = GradientTo::parse(p)?;
let from = s.doc()?.history.undo.len();
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// Before anything expands: outlining the art a dimension measures would leave the dimension unlinked.
let before = s.doc()?.doc.clone();
let selected = s.doc()?.selection.objects.clone();
// A dimension selected with the art it measures keeps its own lines and label: it is out of the
// document while the art expands, so no step below reaches it.
let roots = selected_roots(s)?;
let mut lifted = vec![];
if !super::dimension::with_their_art(&before, &roots).is_empty() {
lifted = s.edit("Expand", |d, _| Ok(super::dimension::lift(d, &roots)))?;
}
let kept: Vec<NodeId> = lifted.iter().map(super::dimension::Lifted::id).collect();
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
let mut ended = vec![];
end_dimension_links(s, &mut ended)?;
let rev0 = s.doc()?.revision;
@@ -339,14 +352,25 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
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));
if !ended.is_empty() || !lifted.is_empty() {
s.edit("Expand", |d, sel| {
if !lifted.is_empty() {
super::dimension::put_back(d, lifted);
// The dimensions put back follow what Expand made of the art they measure: a
// dimension whose art no longer fits its link stays where it was, no longer linked.
super::dimension::refresh(&before, d)?;
let mut ids = sel.objects.clone();
let again: Vec<NodeId> = kept.iter().copied().filter(|id| selected.contains(id) && !ids.contains(id)).collect();
ids.extend(again);
sel.set(ids);
}
// A dimension whose source Expand replaced stays where it was, no longer linked.
super::dimension::restore_unchanged(&before, d, ended);
Ok(())
})?;
if !kept.is_empty() && s.doc()?.doc.layers == before.layers {
return Err(EngineError::Other("Expand: nothing to expand".into()));
}
}
squash(s, from, "Expand");
Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
+10 -6
View File
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Outline Stroke",
["Object", "Path"],
None,
"{} 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}",
"{} replace stroked paths with filled outlines; a dimension whose lines are outlined becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines stay as they are, and if the outlined art no longer fits its link it stays where it was, no longer linked → {ids}",
has_selection,
outline_stroke
),
@@ -1074,7 +1074,11 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
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);
// A dimension selected with the art it measures keeps its own lines: it is out of the
// document while the art is outlined.
let lifted = super::dimension::lift(d, &roots);
let kept = !lifted.is_empty();
let ended = super::dimension::end_links(d, &roots);
let brushes = vectorcraft_brush::library(d);
let mut new_sel = vec![];
let mut changed = 0;
@@ -1083,10 +1087,10 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
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 {
super::dimension::put_back(d, lifted);
// A dimension whose source was outlined stays where it was, no longer linked.
super::dimension::restore_unchanged(&before, d, ended);
if changed == 0 || (kept && d.layers == before.layers) {
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
}
sel.set(new_sel.iter().copied());
+4 -1
View File
@@ -596,6 +596,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
if texts.is_empty() {
return Err(EngineError::Other("Create Outlines: select text objects".into()));
}
let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone());
let ended = super::dimension::end_links(d, &roots);
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
let mut out = vec![];
@@ -691,7 +692,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
new_sel.push(gid);
}
}
super::dimension::restore_unchanged(d, ended);
if let Some(before) = &before {
super::dimension::restore_unchanged(before, 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);
+222 -27
View File
@@ -1164,16 +1164,10 @@ fn an_expand_that_changes_no_art_leaves_a_dimension_live() {
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.
/// A dimension held by a selected group without the art it measures expands as a dimension
/// selected on its own does.
#[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);
fn a_dimension_expanded_in_a_group_without_its_art_stays_plain_art() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
@@ -1183,8 +1177,176 @@ fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() {
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.
/// `command` on a dimension selected with the art it measures, or on a group holding both, leaves
/// the dimension's own lines and label as they are and replaces only the art: the dimension stays
/// where it was, no longer linked, and one undo links it again.
fn keeps_its_own_lines_and_label(command: &str) {
for grouped in [false, true] {
let context = format!("{command}, grouped: {grouped}");
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 live = art(&s, id);
let label = text(&s, id);
let at = bounds(&s, id);
let linked = recipe(&s, id);
let measured = s.doc().unwrap().doc.node(NodeId(art_id)).unwrap().clone();
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
if grouped {
run(&mut s, "object.group", json!({}));
}
let steps = s.doc().unwrap().history.undo.len();
let result = run(&mut s, command, json!({}));
if command == "effect.expandAppearance" {
let mut own = vec![];
s.doc().unwrap().doc.node(NodeId(id)).unwrap().walk(&mut |c| own.push(json!(c.id.0)));
assert!(result["ids"].as_array().unwrap().iter().all(|i| !own.contains(i)), "{context}: {result}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "{context}: one undo step");
assert_ne!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}: the art is replaced");
assert_eq!(art(&s, id), live, "{context}: its own lines and label");
assert_eq!(bounds(&s, id), at, "{context}: where it was");
stays_unlinked(&mut s, id, &label, &context);
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live, "{context}");
assert_eq!(recipe(&s, id), linked, "{context}: one undo links it again");
assert_eq!(s.doc().unwrap().doc.node(NodeId(art_id)), Some(&measured), "{context}");
}
}
#[test]
fn expand_leaves_a_dimension_selected_with_its_art_as_it_is() {
keeps_its_own_lines_and_label("object.expand");
}
#[test]
fn expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is() {
keeps_its_own_lines_and_label("effect.expandAppearance");
}
#[test]
fn outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is() {
keeps_its_own_lines_and_label("object.path.outlineStroke");
}
/// A selected group whose blur Expand Appearance turns into an image takes the dimension it holds,
/// with the art it measures, into that image.
#[test]
fn a_blurred_group_expanded_takes_its_dimension_into_the_image() {
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();
let id = dim(&mut s, art_id);
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[group]}));
run(&mut s, "effect.apply", json!({"effect":"blur.gaussian"}));
run(&mut s, "effect.expandAppearance", json!({}));
let d = &s.doc().unwrap().doc;
assert!(d.node(NodeId(id)).is_none(), "the dimension is part of the image");
let mut images = 0;
d.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
assert_eq!(images, 1);
}
/// Object › Expand keeps a dimension selected with its art in the export assets that list it and
/// bound to its variables.
#[test]
fn expand_keeps_a_dimension_in_its_asset_and_its_variable_binding() {
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"}));
let id = dim(&mut s, art_id);
let asset = run(&mut s, "assets.add", json!({"ids":[id]}))["assets"][0].as_u64().unwrap();
run(&mut s, "variable.define", json!({"name":"Shown","kind":"visibility"}));
run(&mut s, "variable.bind", json!({"variable":"Shown","ids":[id]}));
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
run(&mut s, "object.expand", json!({}));
let d = &s.doc().unwrap().doc;
assert!(d.node(NodeId(id)).is_some_and(|n| n.extras().dimension.is_some()), "still a dimension");
let listed = d.assets.iter().find(|a| a.id == asset).map(|a| a.nodes.clone());
assert_eq!(listed, Some(vec![NodeId(id)]), "in its asset");
assert!(d.variables.bindings.get(&NodeId(id)).is_some_and(|b| b.contains("Shown")), "bound to its variable");
}
/// A selected group with a geometry effect of its own bakes that effect into everything it holds
/// when its appearance expands, the dimension's lines and label included: the dimension becomes a
/// plain group of that art, as one selected without its art does.
#[test]
fn a_group_effect_expanded_reaches_the_dimension_it_holds() {
for command in ["effect.expandAppearance", "object.expand"] {
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();
let id = dim(&mut s, art_id);
let live = art(&s, id);
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
let group = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[group]}));
run(&mut s, "effect.apply", json!({"effect":"distort.puckerBloat"}));
run(&mut s, command, json!({}));
let d = &s.doc().unwrap().doc;
let n = d.node(NodeId(id)).unwrap_or_else(|| panic!("{command}: the dimension's group is still there"));
assert!(n.extras().dimension.is_none(), "{command}: a plain group");
assert_ne!(art(&s, id), live, "{command}: its art is baked with the group's effect");
}
}
/// When the dimension selected with its art is all there is to change, the command fails and
/// changes nothing.
#[test]
fn a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing() {
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, "select.set", json!({"ids":[art_id]}));
run(&mut s, "paint.setStroke", json!({"none": true}));
// A plain path: Expand would make a live rectangle a path.
run(&mut s, "object.expand", json!({}));
let id = dim(&mut s, art_id);
for grouped in [false, true] {
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
if grouped {
run(&mut s, "object.group", json!({}));
}
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
let info = run(&mut s, "object.expand.info", json!({}));
assert_eq!(info["appearance"], json!(false), "grouped: {grouped}: Object › Expand… is not dimmed for the dimension's label");
assert_eq!(info["stroke"], json!(false), "grouped: {grouped}: {info}");
assert!(!vectorcraft_engine::cmd::expand::has_appearance(&s), "grouped: {grouped}: the menus read this");
for (command, p) in [
("object.expand", json!({})),
("object.expand", json!({"object":false,"fill":false})),
("object.path.outlineStroke", json!({})),
("effect.expandAppearance", json!({})),
] {
assert!(s.execute(command, &p).is_err(), "grouped: {grouped}: {command} {p}");
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "grouped: {grouped}: {command} {p}");
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "grouped: {grouped}: {command} {p}");
}
}
}
/// The dimension `id` is a dimension that measures nothing, still showing `label`, and a move
/// takes it along without redrawing it from other art.
fn stays_unlinked(s: &mut Session, id: u64, label: &str, context: &str) {
let live = art(s, id);
assert!(live.2, "{context}: still a dimension");
assert!(recipe(s, id).source.is_none(), "{context}: no longer linked");
assert_eq!(text(s, id), label, "{context}");
let at = bounds(s, id);
run(s, "select.set", json!({"ids":[id]}));
run(s, "object.move", json!({"dx":30,"dy":0}));
assert_eq!(art(s, id), live, "{context}");
assert_eq!(text(s, id), label, "{context}");
let moved = bounds(s, id);
assert!((moved[0] - at[0] - 30.0).abs() < 1e-6 && (moved[1] - at[1]).abs() < 1e-6, "{context}: {at:?} moved to {moved:?}");
run(s, "edit.undo", json!({}));
}
/// Expand replacing only the art a selected dimension measures leaves the dimension where it was,
/// still a dimension, without a link to the art that went; undo links it again.
#[test]
fn a_dimension_keeps_no_link_to_the_art_expand_replaced() {
for circle in [false, true] {
@@ -1197,18 +1359,21 @@ fn a_dimension_keeps_no_link_to_the_art_expand_replaced() {
} else {
dim(&mut s, art_id)
};
let (nodes, labels, _) = art(&s, id);
let live = art(&s, id);
let label = text(&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}");
stays_unlinked(&mut s, id, &label, &format!("circle: {circle}"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live, "circle: {circle}");
assert!(recipe(&s, id).source.is_some(), "circle: {circle}: one undo links it again");
}
}
/// 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.
/// Outline Stroke and Expand Appearance replacing only the art a selected dimension measures
/// leave the dimension where it was, still a dimension, no longer linked; undo links it again.
/// Here the dimension's lines are guides, which neither command changes, and the selection holds
/// the whole dimension or one of its lines.
#[test]
fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() {
for command in ["object.path.outlineStroke", "effect.expandAppearance"] {
@@ -1225,19 +1390,44 @@ fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() {
};
run(&mut s, "select.set", json!({"ids":lines}));
run(&mut s, "view.guides.make", json!({}));
let live = art(&s, id);
let label = text(&s, id);
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}");
stays_unlinked(&mut s, id, &label, &format!("{command}, whole: {whole}"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live, "{command}, whole: {whole}");
assert!(recipe(&s, id).source.is_some(), "{command}, whole: {whole}: one undo links it again");
}
}
}
/// Object › Expand that outlines only the stroke of the art a selected dimension measures leaves
/// the dimension a dimension, no longer linked, as Outline Stroke does.
#[test]
fn a_dimension_outlasts_the_stroke_expand_outlined() {
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"}));
let id = dim(&mut s, art_id);
let lines: Vec<u64> = {
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);
let label = text(&s, id);
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
run(&mut s, "object.expand", json!({"object":false,"fill":false}));
stays_unlinked(&mut s, id, &label, "object.expand stroke");
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live);
assert!(recipe(&s, id).source.is_some(), "one undo links it again");
}
/// 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() {
@@ -1271,10 +1461,11 @@ fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() {
stays_plain_art(&mut s, root);
}
/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step.
/// Create Outlines on a dimension, also one selected with the art it measures, 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] {
for target in ["dimension", "label", "with its art"] {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
@@ -1283,8 +1474,12 @@ fn outlining_a_dimension_or_its_label_ends_its_link() {
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]}));
let ids = match target {
"label" => vec![label.0],
"with its art" => vec![line, id],
_ => vec![id],
};
run(&mut s, "select.set", json!({"ids":ids}));
run(&mut s, "type.createOutlines", json!({}));
stays_plain_art(&mut s, id);
run(&mut s, "edit.undo", json!({}));
+40
View File
@@ -462,3 +462,43 @@ fn expand_appearance_keeps_a_plug_in_effect_safe_mode_skips() {
safe_mode::set_active(Some(safe_mode::Source::Flag));
s.execute("effect.expandAppearance", &json!({})).unwrap();
}
/// In Safe Mode, a plug-in effect on a dimension selected with the art it measures does not stop
/// Expand Appearance: the dimension is left as it is, its effect included, and the art expands.
#[test]
fn expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art() {
let _f = fresh();
let mut s = Session::new();
let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.dim", "effect", "{}"), r#"{"objects":[]}"#)).unwrap();
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let art = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 72, "height": 72})).unwrap()["id"].clone();
s.execute("paint.setStroke", &json!({"ids": [art], "color": "#000000"})).unwrap();
s.execute("appearance.addStroke", &json!({})).unwrap();
let source = json!({"kind": "segment", "edge": {"node": art, "subpath": 0, "segment": 0}});
let dim = s.execute("dimension.create", &json!({"source": source})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [dim]})).unwrap();
s.execute("effect.apply", &json!({"effect": "plugin.test.sm.dim"})).unwrap();
s.execute("select.set", &json!({"ids": [art, dim]})).unwrap();
let node = |s: &mut Session, id: &Value| {
let inspect = s.execute("document.inspect", &json!({})).unwrap();
let mut found = Value::Null;
let mut stack = vec![inspect["layers"].clone()];
while let Some(v) = stack.pop() {
match v {
Value::Array(a) => stack.extend(a),
Value::Object(o) if o.get("id") == Some(id) => found = Value::Object(o),
Value::Object(o) => stack.extend(o.into_iter().map(|(_, v)| v)),
_ => {}
}
}
found
};
let kept = node(&mut s, &dim);
let measured = node(&mut s, &art);
safe_mode::set_active(Some(safe_mode::Source::Flag));
s.execute("effect.expandAppearance", &json!({})).unwrap();
safe_mode::set_active(None);
assert_ne!(node(&mut s, &art), measured, "the art expands");
assert_eq!(node(&mut s, &dim), kept, "the dimension and its effect stay as they are");
}
+21 -8
View File
@@ -74,14 +74,27 @@ paths and text as ordinary art. Object › Expand, Object › Expand Appearance,
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. 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.
Object › Expand… while a whole dimension is selected without the art it measures, because its label
has appearance items; Object › Expand Appearance and the `object.expand` command still reach it.
When a whole dimension is selected together with art it measures, directly or in selected groups
without fills, strokes or effects of their own, Object › Expand, Object › Expand Appearance and
Object › Path › Outline Stroke leave the dimension's lines and label as they are and change only the
other art. The menus do not dim Object › Expand… for the label of such a dimension. If the new art no
longer fits the dimension's link, for example because its number of anchors changed, the dimension
stays where it was, no longer linked, as it does when its art is deleted. Undoing the command links
it again. When the art keeps its id and anchor count, the dimension stays linked and its label shows
the new length. When the dimension is all the command would change, the command changes nothing and
reports the error it gives for a selection with nothing to change: "Expand: nothing to expand",
"Expand Appearance: the selection has a basic appearance" or "Outline Stroke: no stroked paths
selected". A selected group with fills, strokes or effects of its own changes everything it holds
when its appearance expands, its dimensions included: a geometry effect is baked into their lines
and label, which become a plain group, and a blur, feather or inner glow turns them into part of the
image that replaces the group's art. Type › Create Outlines still outlines the label of a dimension
selected with its art, which turns the dimension into a plain group.
Pasted dimensions keep their geometry and drop their links, so they never bind to unrelated art in
another document.
## Commands
+4 -1
View File
@@ -3652,7 +3652,10 @@ holds symbol instances that `object` expands, whether only their art holds it, w
`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)).
art they change into a plain group of that art, in the same undo step. `object.expand`, `effect.expandAppearance` and
`object.path.outlineStroke` leave a dimension selected with art it measures, directly or in groups without fills,
strokes or effects of their own, as it is and change only the other art; if the new art no longer fits the dimension's
link, the dimension stays where it was, no longer linked (see [dimensions.md](dimensions.md)).
`object.expand` rejects a param of the wrong type, such as `"fill": "yes"`, `"gradient": 3` or `"steps": "16"`, and
changes nothing. Omitting a param or giving null keeps its default.
+1
View File
@@ -566,6 +566,7 @@ since 2026-10-01.
**Drawing tools, Star inner radius held with Ctrl/Cmd:** While a star is dragged out, Ctrl/Cmd keeps the inner radius the star showed when the key went down, also when the key is pressed with the pointer still. `a_star_drag_holds_its_inner_radius_with_ctrl_and_straightens_with_alt` and `a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down` cover it, with Alt's straight shoulders and a drag canceled with Escape.
**Drawing tools, Dimension tool horizontal and vertical linear dimensions:** A linear dimension between two clicked points or dragged out from a segment is aligned, horizontal or vertical, chosen by where the pointer moves while it is placed, and a click again by the second point gives aligned; `dimension.create` and `dimension.update` take `orientation`, and the dimension stays linked to the anchors it measures, keeps its orientation when it or the art turns, with a line that lay outside the points kept outside them; an unlinked horizontal or vertical dimension that is reflected has its line on the reflected side. `the_pointer_chooses_aligned_horizontal_or_vertical`, `two_points_measure_horizontally_vertically_or_aligned`, `clicking_again_by_the_second_point_places_an_aligned_dimension`, `a_segment_drag_measures_horizontally_vertically_or_aligned`, `points_on_a_vertical_line_measure_aligned`, `horizontal_and_vertical_dimensions_draw_lines_along_their_axis`, `turned_horizontal_dimensions_stay_horizontal`, `a_linked_horizontal_dimension_keeps_its_gap_from_turned_art`, `a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art`, `a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side`, `a_dimension_without_an_orientation_reads_as_aligned`, `dimension_orientation_is_checked` and `a_turned_dimension_keeps_its_line_on_its_side` cover it.
**Drawing tools, Dimension tool points that are not anchors follow the art:** A clicked point on a segment stays at its place along the segment, also when the segment curves, and a point off the art is linked to anchors (or, when the anchors lie on one line, anchors and handles) of the path, so both ends of the dimension follow when the art is moved, scaled, turned or reflected, also when the point was placed by a Smart Guide and when the dimension is saved and opened again; a linked linear dimension of reflected art (other than art on one line) has its line on the reflected side; deleting the art, changing its anchor count or whether it is closed, turning an angle's arm into a curve, or making a measured circle or arc no longer circular leaves the dimension where it was, no longer linked, instead of removing it. `a_dimension_to_a_point_on_a_segment_follows_the_art_at_both_ends`, `a_dimension_to_a_point_off_the_art_moves_with_the_art`, `a_dimension_whose_art_changes_its_anchors_stays_where_it_was`, `a_dimension_whose_art_is_opened_stays_where_it_was`, `angle_and_arc_dimensions_whose_art_no_longer_fits_stay_where_they_were`, `a_dimension_that_cannot_be_drawn_again_keeps_its_art`, `a_linked_dimension_of_reflected_art_has_its_line_on_the_reflected_side`, `a_dimension_to_a_point_off_a_line_follows_the_line`, `a_point_off_an_arch_is_reflected_with_the_arch`, `a_point_beside_an_almost_straight_curve_follows_it`, `a_point_off_a_loop_with_its_anchors_in_one_place_follows_it`, `a_point_beside_a_reflected_line_moves_as_a_half_turn_would`, `a_point_on_a_segment_stays_on_it_when_the_segment_curves`, `a_linked_dimension_of_a_reflected_curve_has_its_line_on_the_reflected_side`, `a_dimension_of_art_reflected_across_its_points_changes_sides`, `a_dimension_between_paths_reflected_each_in_its_own_way_keeps_its_offset`, `free_points_survive_a_native_file`, `dimension_free_points_are_checked`, `points_off_anchors_are_held_against_the_path` and `a_point_smart_guides_place_is_held_against_the_path` cover it.
**Drawing tools, Dimension tool dimensions selected with their art keep their lines and label:** Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave a dimension selected with art it measures, directly or in selected groups without fills, strokes or effects of their own, as it is and change only the other art; when the new art no longer fits its link, the dimension stays where it was, no longer linked, and undo links it again. `expand_leaves_a_dimension_selected_with_its_art_as_it_is`, `expand_appearance_leaves_a_dimension_selected_with_its_art_as_it_is`, `outline_stroke_leaves_a_dimension_selected_with_its_art_as_it_is`, `a_dimension_selected_with_art_that_has_nothing_to_expand_changes_nothing`, `a_group_effect_expanded_reaches_the_dimension_it_holds`, `expand_keeps_a_dimension_in_its_asset_and_its_variable_binding`, `a_dimension_keeps_no_link_to_the_art_expand_replaced`, `a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced`, `a_dimension_outlasts_the_stroke_expand_outlined` and `expand_appearance_in_safe_mode_leaves_a_dimension_selected_with_its_art` cover it.
**Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling.
**Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo.