diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index b958f51c1..2b0e214cc 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -262,7 +262,59 @@ impl Form { /// type, linked files, objects with an opacity mask, clipping paths or guides (the help page lists /// images, text, masks and links as unsupported). fn supported(n: &Node) -> bool { - n.extras().mask.is_none() && Form::of(n).is_some() + n.extras().mask.is_none() && has_form(n) +} + +/// Does [`Form::of`] give `n` a form? The same walk and the same box, without collecting the +/// points or placing them in a unit box. +fn has_form(n: &Node) -> bool { + let mut b = None; + form_box(n, 0, &mut b).is_some() + && b.is_some_and(|b: Rect| { + let side = b.width().max(b.height()); + side.is_finite() && side > 1e-9 + }) +} + +/// [`Form::add`] without the form: `b` grows by each point it would add; `None` when `n` isn't +/// supported. +fn form_box(n: &Node, depth: usize, b: &mut Option) -> Option<()> { + match &n.kind { + NodeKind::Path { path, clipping, guide: false, .. } if !*clipping || depth > 0 => grow_by_path(b, path), + NodeKind::Compound { children, .. } => { + for c in children { + grow_by_path(b, c.path_data()?); + } + } + NodeKind::SymbolInstance { xf, .. } => { + for p in [*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)] { + grow(b, p); + } + } + NodeKind::Group { children, .. } if plain_group(n) && depth < vectorcraft_doc::MAX_DEPTH => { + for c in children { + if c.extras().mask.is_some() { + return None; + } + form_box(c, depth + 1, b)?; + } + } + _ => return None, + } + Some(()) +} + +fn grow_by_path(b: &mut Option, path: &PathData) { + for a in path.subpaths.iter().flat_map(|sp| &sp.anchors) { + for p in [a.p, a.h_in, a.h_out] { + grow(b, p); + } + } +} + +/// `b` grown to take in `p`, as [`Form::of`] measures its points. +fn grow(b: &mut Option, p: Point) { + *b = Some(b.unwrap_or(Rect::from_points(p, p)).union_pt(p)); } /// A plain group (a clip group too), not one that is live art (a graph, Shaper group, dimension, @@ -374,8 +426,9 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; - // A group's contents are checked when Global Edit starts: building its shape here would walk - // every object in it each time the menu or the Properties panel asks. + // A group's contents are checked when Global Edit starts: measuring them here would walk + // every object in it each time the menu or the Properties panel asks. Other objects are + // measured ([`supported`]), not reduced to their form: the Control bar asks every frame. match st.doc.node(*id) { Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()), Some(n) if supported(n) => Ok(()), @@ -750,3 +803,195 @@ pub fn highlights(s: &Session) -> Outlines { *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); out } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use vectorcraft_doc::OpacityMask; + use vectorcraft_geom::{Anchor, SubPath}; + + /// Global Edit's support as it was decided by building the form. + fn by_form(n: &Node) -> bool { + n.extras().mask.is_none() && Form::of(n).is_some() + } + + /// A small seeded generator (xorshift64*), so that a failure repeats. + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + fn unit(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + /// A spread for the points of one object: none, below and around the smallest size a form + /// takes, ordinary, or past every bound. + fn spread(&mut self) -> f64 { + [0.0, 1e-10, 5e-10, 1e-9, 2e-9, 1.0, 1.0, 100.0, 100.0, 100.0, f64::INFINITY, f64::NAN][self.below(12) as usize] + } + fn point(&mut self, base: Point, spread: f64) -> Point { + let mut c = |b: f64| match self.below(400) { + 0 => f64::NAN, + 1 => f64::INFINITY, + 2 => f64::NEG_INFINITY, + _ => b + (self.unit() - 0.5) * spread, + }; + Point::new(c(base.x), c(base.y)) + } + } + + fn path_data(r: &mut Rng) -> PathData { + let (base, spread) = (Point::new(r.unit() * 200.0, r.unit() * 200.0), r.spread()); + let subpaths = (0..r.below(3)) + .map(|_| { + let anchors = (0..r.below(4)) + .map(|_| Anchor { p: r.point(base, spread), h_in: r.point(base, spread), h_out: r.point(base, spread), ..Anchor::corner(base) }) + .collect(); + SubPath::new(anchors, r.below(2) == 0) + }) + .collect(); + PathData::new(subpaths) + } + + /// A random object: paths (clipping paths and guides too), compound paths, symbol instances, + /// groups (clip groups, live art, members with an opacity mask), type, blends and intertwined + /// objects, any of them with an opacity mask. + fn object(r: &mut Rng, d: &mut Document, text: &Node, depth: usize) -> Node { + let leaf = depth >= 3; + let mut n = match r.below(if leaf { 4 } else { 8 }) { + 0 | 1 => { + let mut n = Node::path(d.alloc_id(), path_data(r), Appearance::default()); + if let NodeKind::Path { clipping, guide, .. } = &mut n.kind { + *clipping = r.below(6) == 0; + *guide = r.below(8) == 0; + } + n + } + 2 => { + let xf = match r.below(4) { + 0 => Affine::scale(0.0), + 1 => Affine::scale(1e-10), + _ => Affine::translate((r.unit() * 100.0, r.unit() * 100.0)) * Affine::scale(r.spread()), + }; + Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf }) + } + 3 => { + let mut t = text.clone(); + t.id = d.alloc_id(); + t + } + 4 => { + let children = (0..r.below(4)) + .map(|_| match r.below(5) { + 0 => Arc::new(object(r, d, text, depth + 1)), + _ => Arc::new(Node::path(d.alloc_id(), path_data(r), Appearance::default())), + }) + .collect(); + Node::new(d.alloc_id(), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::NonZero }) + } + 5 => { + let children = (0..r.below(3)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect(); + Node::new(d.alloc_id(), NodeKind::Blend { children, spec: Box::default() }) + } + _ => { + let children = (0..r.below(4)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect(); + let mut g = Node::new(d.alloc_id(), NodeKind::Group { children, clip: r.below(3) == 0 }); + if r.below(8) == 0 { + g.extras_mut().trace = Some(Box::new(Value::Null)); + } + g + } + }; + if r.below(10) == 0 { + n.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), path_data(r), Appearance::default()), true))); + } + n + } + + /// `n` and every object in it. + fn every(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |c| out.push(c.clone())); + out + } + + #[test] + fn support_is_decided_as_building_the_form_decides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let text = s.doc().unwrap().doc.node(t).unwrap().clone(); + let layer = s.doc().unwrap().doc.layers[0].id; + let (mut checked, mut taken, mut asked) = (0, 0, 0); + for seed in 1..=400u64 { + let mut r = Rng(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1); + let n = object(&mut r, Arc::make_mut(&mut s.doc_mut().unwrap().doc), &text, 0); + for c in every(&n) { + assert_eq!(supported(&c), by_form(&c), "seed {seed}: {c:?}"); + checked += 1; + taken += usize::from(by_form(&c)); + } + // The enabled check of Start Global Edit on it, and Start Global Edit itself. + let id = Arc::make_mut(&mut s.doc_mut().unwrap().doc).insert(Some(layer), usize::MAX, n.clone()).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + if s.doc().unwrap().selection.objects != [id] { + continue; + } + asked += 1; + let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n); + assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}"); + match s.execute(C, &json!({})) { + Ok(_) => { + assert!(by_form(&n), "seed {seed}"); + s.execute(C, &json!({})).unwrap(); + } + Err(e) => assert_eq!(e.to_string().contains(UNSUPPORTED), !by_form(&n), "seed {seed}: {e}"), + } + } + // Both answers came up often, and most objects could be selected alone. + assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected"); + } + + #[test] + fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() { + let mut d = Document::new(800.0, 600.0); + let corner = |x: f64, y: f64| Anchor::corner(Point::new(x, y)); + let path = |d: &mut Document, subpaths: Vec| Node::path(d.alloc_id(), PathData::new(subpaths), Appearance::default()); + let mut cases = vec![ + path(&mut d, vec![]), + path(&mut d, vec![SubPath::new(vec![], true)]), + path(&mut d, vec![SubPath::new(vec![corner(5.0, 5.0)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 1e-9)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 2e-9)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(f64::INFINITY, 0.0)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, 0.0), corner(10.0, 0.0), corner(0.0, 0.0)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, f64::NAN)], false)]), + Node::new(d.alloc_id(), NodeKind::Compound { children: vec![], rule: Default::default() }), + Node::group(d.alloc_id(), vec![]), + Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::scale(0.0) }), + Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }), + ]; + // A group as deep as the walk goes, and one level deeper. + let mut deep = path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(10.0, 10.0)], false)]); + for _ in 0..=vectorcraft_doc::MAX_DEPTH { + deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]); + cases.push(deep.clone()); + } + let expected = [false, false, false, false, true, false, true, false, false, false, false, true]; + for (i, n) in cases.iter().enumerate() { + assert_eq!(supported(n), by_form(n), "case {i}: {n:?}"); + if let Some(e) = expected.get(i) { + assert_eq!(supported(n), *e, "case {i}"); + } + } + assert!(supported(&cases[cases.len() - 2]) && !supported(&cases[cases.len() - 1])); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 343b650e4..17db9d3f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -512,6 +512,7 @@ since 2026-10-01. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. +- **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged.