From 897d99a70f81053306c942aeee59139ba0945734 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 00:53:00 +0700 Subject: [PATCH] Fix #1448, #1460: Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle --- crates/doc/src/pattern.rs | 9 +++ crates/engine/src/cmd/menucmds.rs | 5 +- crates/engine/src/cmd/object.rs | 39 +++++++--- crates/engine/src/tests.rs | 5 +- crates/engine/src/tests_pattern.rs | 39 ++++++++++ crates/ui-egui/src/dialogs/form.rs | 5 +- crates/ui-egui/src/dialogs/tools.rs | 2 +- crates/ui-egui/src/dialogs/transform.rs | 77 ++++++++++++++++---- crates/ui-egui/src/dialogs/transform_each.rs | 18 +++-- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 2 +- 23 files changed, 174 insertions(+), 40 deletions(-) diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 39f4e11a..dc19544f 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -341,6 +341,15 @@ pub fn uses_pattern(n: &Node, name: Option<&str>) -> bool { }) } +/// Transform Patterns without Transform Objects: `m` moves the pattern tiles of `n` and of +/// everything inside it, and the art stays where it is. +pub fn transform_patterns_only(n: &mut Node, m: Affine) { + transform_pattern_paints(n, m); + for c in n.children_mut().into_iter().flatten() { + transform_patterns_only(Arc::make_mut(c), m); + } +} + /// Apply `m` to the pattern placement of every pattern paint of `n` (Transform Patterns). pub fn transform_pattern_paints(n: &mut Node, m: Affine) { // Without pattern paints there is nothing to change: don't copy the shared appearance. diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 4a9016bd..23555023 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Transform Each…", ["Object", "Transform"], Some("Cmd+Alt+Shift+D"), - "{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences), patterns?: bool (Transform Patterns; default: prefs transformPatternTiles)} transform every selected object about its own reference point → {ids}", + "{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns; default: prefs transformPatternTiles)} transform every selected object about its own reference point → {ids}", has_selection, transform_each ), @@ -307,6 +307,7 @@ fn transform_each(s: &mut Session, p: &Value) -> Result { let random = bool_or(p, "random", false); let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; let copy = bool_or(p, "copy", false); + let objects = super::object::transforms_objects(p); if sh == 0.0 || sv == 0.0 { return Err(bad("object.transformEach", "scale must be non-zero")); } @@ -336,7 +337,7 @@ fn transform_each(s: &mut Session, p: &Value) -> Result { let m = Affine::translate((k_mh, k_mv)) * about(o, Affine::rotate(-k_rot.to_radians()) * refl * Affine::scale_non_uniform(k_sh / 100.0, k_sv / 100.0)); if let Some(n) = d.node_mut(*id) { - n.transform(m, sc); + super::object::transform_node(n, m, sc, objects); } } Ok(targets) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bb6e54f9..0598d0da 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Transform", [], None, - "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)", + "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)", has_doc, transform ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Move…", ["Object", "Transform"], Some("Cmd+Shift+M"), - "{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{dx, dy, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, move_cmd ), @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Rotate…", ["Object", "Transform"], None, - "{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, rotate ), @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Scale…", ["Object", "Transform"], None, - "{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, scale ), @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Reflect…", ["Object", "Transform"], None, - "{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Shear…", ["Object", "Transform"], None, - "{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, shear ), @@ -267,8 +267,24 @@ pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> Ok(on) } +/// Transform Objects (`objects` param, on unless it is false): off, a transform leaves the art +/// where it is and, with Transform Patterns, moves only its pattern tiles. +pub(crate) fn transforms_objects(p: &Value) -> bool { + bool_or(p, "objects", true) +} + +/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. +pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { + if objects { + n.transform(xf, sc); + } else if sc.patterns { + vectorcraft_doc::pattern::transform_patterns_only(n, xf); + } +} + /// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]; -/// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Outside an +/// `patterns`: see [`transform_patterns`]; `objects`: see [`transforms_objects`]; `typeAreas`: +/// see [`resize_type_area`]). Outside an /// interaction, records `xf` and the options it used for Transform Again. In an interaction whose /// document is still its snapshot, a transform with `copy` keeps its copies ([`preview_copies`]), /// and the next one that copies the same objects of the same snapshot transforms those copies. @@ -278,6 +294,7 @@ pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value, anchors: bool) -> Result { let copy = bool_or(p, "copy", false); let areas = bool_or(p, "typeAreas", false); + let objects = transforms_objects(p); let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; sc.patterns = transform_patterns(s, p, &ids)?; // A transform of ids that name no object, or an identity transform without a copy, moves @@ -285,7 +302,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf // With a copy, the reply lists the copies, and there are none. let d = &s.doc()?.doc; let none = d.index_paths(ids.iter().copied()).is_empty(); - let ids = if anchors && !copy && !s.doc()?.selection.anchors.is_empty() { + let ids = if anchors && objects && !copy && !s.doc()?.selection.anchors.is_empty() { if xf != Affine::IDENTITY { super::path::transform_anchors(s, label, xf, sc)?; } @@ -310,9 +327,9 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf let paths = d.index_paths(targets.iter().copied()); for id in &targets { if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p)) - && !(areas && resize_type_area(n, xf)) + && !(objects && areas && resize_type_area(n, xf)) { - n.transform(xf, sc); + transform_node(n, xf, sc, objects); } } Ok(targets) @@ -320,7 +337,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf }; let st = s.doc_mut()?; if st.interaction.is_none() { - let mut options = json!({ "copy": copy, "typeAreas": areas, "patterns": sc.patterns }); + let mut options = json!({ "copy": copy, "typeAreas": areas, "patterns": sc.patterns, "objects": objects }); if Scaling::factor(xf).is_some() { options["strokes"] = json!(sc.strokes); options["corners"] = json!(!sc.keep_corners); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ec7dba73..fcffaf02 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -712,7 +712,10 @@ fn a_transform_that_moves_nothing_records_no_undo_step() { assert_eq!(history(&s), before, "{id} {p}"); assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); } - assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, json!({"copy": false, "typeAreas": false, "patterns": false})))); + assert_eq!( + s.doc().unwrap().last_transform, + Some((Affine::IDENTITY, json!({"copy": false, "typeAreas": false, "patterns": false, "objects": true}))) + ); // A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape. let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. }); assert!(is_live(&s), "a rectangle is a live shape"); diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 0973bf06..41282173 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -205,6 +205,45 @@ fn transform_patterns_only_moves_placement() { assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 5.0, 0.0]); } +/// #1448, #1460: with Transform Objects off, the transform commands leave the art where it is and +/// move only its pattern tiles (Transform Patterns on), or nothing at all; Transform Again repeats +/// that. +#[test] +fn transforms_without_transform_objects_move_only_the_pattern() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [big.0]})).unwrap(); + let before = node(&s, big).geometric_bounds(); + let tiles = |s: &Session| { + let n = node(s, big); + let Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!() }; + xf.as_coeffs() + }; + for (cmd, p, want) in [ + ("object.move", json!({"dx": 5, "dy": 0}), [1.0, 0.0, 0.0, 1.0, 5.0, 0.0]), + ("object.transformAgain", json!({}), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0]), + ("object.reflect", json!({"axis": "vertical", "origin": [0, 0]}), [-1.0, 0.0, 0.0, 1.0, -10.0, 0.0]), + ("object.transformEach", json!({"moveH": 10, "reference": 0}), [-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), + ] { + // Transform Again repeats the move as it was made, options included. + if cmd != "object.transformAgain" { + let mut off = p.clone(); + off["objects"] = json!(false); + off["patterns"] = json!(false); + s.execute(cmd, &off).unwrap(); + assert_eq!(node(&s, big).geometric_bounds(), before, "{cmd}: neither transforms"); + off["patterns"] = json!(true); + s.execute(cmd, &off).unwrap(); + } else { + s.execute(cmd, &p).unwrap(); + } + assert_eq!(node(&s, big).geometric_bounds(), before, "{cmd}"); + assert_eq!(tiles(&s), want, "{cmd}"); + } +} + #[test] fn list_reports_patterns_and_edit_state() { let mut s = session(); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 825b451d..08e78914 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -64,7 +64,7 @@ fn lengths(kind: &str) -> &'static [&'static str] { /// number: fields that ↑/↓ step by one (Shift: ten) and that do math, as the distances do. fn suffix(kind: &str, key: &str) -> Option<&'static str> { match (kind, key) { - ("rotate" | "shear", "angle") => Some("°"), + ("rotate" | "shear" | "reflect", "angle") => Some("°"), ("scale", "sx" | "sy") => Some("%"), _ => None, } @@ -103,7 +103,8 @@ fn choices(kind: &str, key: &str) -> Vec<(&'static str, &'static str)> { match (kind, key) { ("offsetPath", "joins") => vec![("miter", tl!("Miter")), ("round", tl!("Round")), ("bevel", tl!("Bevel"))], ("average", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("both", "Both")], - ("reflect" | "shear", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical")], + ("reflect", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical"), axis("angle", "Angle")], + ("shear", "axis") => vec![axis("horizontal", "Horizontal"), axis("vertical", "Vertical")], _ => vec![], } } diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index 11711aef..034295ff 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -44,7 +44,7 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { "rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => { let mut base = match kind { "rotate" => json!({"angle": 0}), - "reflect" => json!({"axis": "vertical"}), + "reflect" => json!({"axis": "vertical", "angle": 90}), "scale" => json!({"sx": 100, "sy": 100, "uniform": true}), "shear" => json!({"angle": 0, "axis": "horizontal"}), _ => json!({}), diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 8e385b1e..60a1019d 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -1,5 +1,6 @@ -//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Patterns, Copy and -//! Preview; Scale also has Uniform, Scale Corners and Scale Strokes & Effects). With Preview on +//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Objects, Transform +//! Patterns, Copy and Preview; Scale also has Uniform, Scale Corners and Scale Strokes & Effects; +//! Reflect's axis is Horizontal, Vertical or an angle). With Preview on //! (`preview`, off when the dialog opens), the transform shows on the canvas as the values change. use serde_json::{Value, json}; @@ -27,8 +28,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "uniform", tl!("Uniform")); scale_options(app, ui, d); } - d.fields.insert("patterns".into(), Value::Bool(transform_patterns(app, d))); - form::check(ui, d, "patterns", tl!("Transform Patterns")); + object_options(app, ui, d); form::check(ui, d, "copy", tl!("Copy (make a transformed copy)")); let (cmd, mut params) = command(app, d); if d.kind == "scale" { @@ -43,6 +43,26 @@ fn transform_patterns(app: &VectorcraftApp, d: &Dialog) -> bool { d.fields.get("patterns").and_then(Value::as_bool).unwrap_or(app.session.prefs.transform_pattern_tiles) } +/// Transform Objects: the dialog's field, on until it is switched off (the pattern tiles alone +/// transform then, with Transform Patterns). +fn transform_objects(d: &Dialog) -> bool { + d.fields.get("objects").and_then(Value::as_bool).unwrap_or(true) +} + +/// The Transform Objects and Transform Patterns checkboxes (fields `objects` and `patterns`). +pub(super) fn object_options(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + d.fields.insert("objects".into(), Value::Bool(transform_objects(d))); + form::check(ui, d, "objects", tl!("Transform Objects")); + d.fields.insert("patterns".into(), Value::Bool(transform_patterns(app, d))); + form::check(ui, d, "patterns", tl!("Transform Patterns")); +} + +/// Add the dialog's Transform Objects and Transform Patterns options to command `params`. +pub(super) fn add_object_options(app: &VectorcraftApp, d: &Dialog, params: &mut Value) { + params["objects"] = json!(transform_objects(d)); + params["patterns"] = json!(transform_patterns(app, d)); +} + /// The scale options: (dialog field, preference, label). const SCALE_OPTIONS: [(&str, &str, &str); 2] = [("corners", "scaleCorners", "Scale Corners"), ("strokes", "scaleStrokes", "Scale Strokes & Effects")]; @@ -88,22 +108,23 @@ fn origin_params(d: &Dialog, mut p: Value) -> Value { /// The dialog's command and its params (Scale's without its scale options). fn command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) { - let (copy, patterns) = (d.bool("copy"), transform_patterns(app, d)); - let (id, params) = match d.kind.as_str() { - // Move has no reference point. - "move" => return ("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy, "patterns": patterns})), - "rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy, "patterns": patterns})), + let (id, mut params) = match d.kind.as_str() { + "move" => ("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0)})), + "rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0)})), "scale" => { let sx = d.f64("sx", 100.0); let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) }; - ("object.scale", json!({"sx": sx, "sy": sy, "copy": copy, "patterns": patterns})) + ("object.scale", json!({"sx": sx, "sy": sy})) } - "reflect" => { - ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy, "patterns": patterns})) - } - _ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy, "patterns": patterns})), + // The axis by name, or the angle it is at. + "reflect" if d.str("axis") == "angle" => ("object.reflect", json!({"axis": d.f64("angle", 90.0)})), + "reflect" => ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical"))})), + _ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis")})), }; - (id, origin_params(d, params)) + params["copy"] = json!(d.bool("copy")); + add_object_options(app, d, &mut params); + // Move has no reference point. + if id == "object.move" { (id, params) } else { (id, origin_params(d, params)) } } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { @@ -214,6 +235,32 @@ mod tests { assert_eq!(size(&app), (60.0, 20.0)); } + /// #1460: Reflect's axis is Horizontal, Vertical or an angle, and Transform Objects goes with + /// the command (on until switched off). + #[test] + fn reflect_takes_an_angle_and_transform_objects() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 20})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (axis, objects, size) in [("angle", true, (20.0, 60.0)), ("vertical", false, (20.0, 60.0))] { + crate::menus::invoke(&mut app, "object.reflect", json!({})); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((&d.fields["objects"], &d.fields["angle"]), (&json!(true), &json!(90))); + d.fields.extend([("axis".to_string(), json!(axis)), ("angle".to_string(), json!(45)), ("objects".to_string(), json!(objects))]); + crate::dialogs::confirm(&mut app).unwrap(); + let (cmd, p) = app.session.journal.last().unwrap(); + let want = if axis == "angle" { json!(45.0) } else { json!(axis) }; + assert_eq!((cmd.as_str(), &p["axis"], &p["objects"]), ("object.reflect", &want, &json!(objects))); + // Across the 45° axis the rectangle's sides swap; without Transform Objects it stays. + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert_eq!((b.width().round(), b.height().round()), size); + } + } + /// Transform Patterns starts from General › Transform Pattern Tiles (#394) and goes with the /// command. #[test] diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index 85a390c0..51553051 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -1,15 +1,15 @@ //! Object → Transform → Transform Each: scale, move and rotate every selected object about its own -//! reference point, with reflection, randomness, Scale Corners and Scale Strokes & Effects -//! (`object.transformEach`), previewed live on the canvas. Its Scale and Move sliders, Rotate dial +//! reference point, with reflection, randomness, Transform Objects, Transform Patterns, Scale +//! Corners and Scale Strokes & Effects (`object.transformEach`), previewed live on the canvas. Its Scale and Move sliders, Rotate dial //! and reflection, reference point and Random options are the Transform effect's too. //! //! Fields: `scaleH`, `scaleV` (%), `moveH`, `moveV` (pt), `rotate` (°), `reflectX`, `reflectY`, -//! `random`, `reference` (0..8, the 9-point grid), `copy`, `corners`, `strokes` (default: the -//! preferences, which OK updates) and `preview`. +//! `random`, `reference` (0..8, the 9-point grid), `copy`, `objects`, `patterns`, `corners`, +//! `strokes` (the last two default to the preferences, which OK updates) and `preview`. use serde_json::{Value, json}; -use super::transform::{add_scale_options, scale_options, with_scale_options}; +use super::transform::{add_object_options, add_scale_options, object_options, scale_options, with_scale_options}; use super::{DialogSpec, form}; use crate::VectorcraftApp; use crate::state::Dialog; @@ -102,19 +102,23 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.vertical(|ui| { widgets::subheader(ui, tl!("Options")); ui.add_space(4.0); + object_options(app, ui, d); scale_options(app, ui, d); reflect_options(ui, d); form::check(ui, d, "copy", tl!("Copy")); }); }); let mut p = params(d); + add_object_options(app, d, &mut p); add_scale_options(app, d, &mut p); form::preview(app, ui, d, "Transform Each", CMD, p); false } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let p = with_scale_options(app, d, params(d))?; + let mut p = params(d); + add_object_options(app, d, &mut p); + let p = with_scale_options(app, d, p)?; form::commit_preview(app, CMD, p) } @@ -150,6 +154,6 @@ mod tests { assert!((n.geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6); assert_eq!(n.appearance.stroke_width(), 1.0, "strokes kept their weight"); let (cmd, p) = app.session.journal.last().unwrap(); - assert_eq!((cmd.as_str(), &p["strokes"]), (CMD, &json!(false))); + assert_eq!((cmd.as_str(), &p["strokes"], &p["objects"]), (CMD, &json!(false), &json!(true)), "#1448: Transform Objects goes along"); } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8069b93c..3d3e9a2c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4679,3 +4679,4 @@ mark-style Japanese Japonské @msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar + Transform Objects Transformovat objekty diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b271a586..7a5eda7d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4202,3 +4202,4 @@ theme Appearance Erscheinungsbild @msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form + Transform Objects Objekte transformieren diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9664be52..3dccf183 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4619,3 +4619,4 @@ mark-style Japanese Japonesas @msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma + Transform Objects Transformar objetos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c8e81339..53f27588 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4255,3 +4255,4 @@ lighting Light Lumière @msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme + Transform Objects Transformer les objets diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f89a3dcf..0ac8a0c8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4645,3 +4645,4 @@ theme Appearance Kinézet @msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot + Transform Objects Objektumok transzformálása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index edd90df1..c543ceec 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4247,3 +4247,4 @@ lighting Light Luce @msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma + Transform Objects Trasforma oggetti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 32899992..a299f771 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4241,3 +4241,4 @@ lighting Light ライト @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください + Transform Objects オブジェクトの変形 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c3f1595c..dd7932a8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4211,3 +4211,4 @@ lighting Light 광원 @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 + Transform Objects 개체 변형 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5fe36b5d..b1e00c7f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3688,3 +3688,4 @@ axis Both Ambos Create Compound Path From Ellipses Criar Caminho Composto a partir das Elipses Fill Grid Preencher Grade Select two or more objects, or a group Selecione dois ou mais objetos, ou um grupo + Transform Objects Transformar objetos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9c51c488..a3ff23ab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4243,3 +4243,4 @@ lighting Light Свет @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру + Transform Objects Трансформировать объекты diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 55c2cab9..f3293d4f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4227,3 +4227,4 @@ lighting Light Світло @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру + Transform Objects Трансформувати об’єкти diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c07ef172..3aac3231 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3838,3 +3838,4 @@ lighting Light 光源 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 + Transform Objects 变换对象 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 54d8c344..ac20e629 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3619,3 +3619,4 @@ lighting Light 光源 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 + Transform Objects 變形物件 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 47130d6a..349fb234 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3017,7 +3017,7 @@ fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> "object.rotate" => ("rotate", json!({"angle": 0})), "object.scale" => ("scale", json!({"sx": 100, "sy": 100, "uniform": true})), "object.transformEach" => (crate::dialogs::transform_each::KIND, crate::dialogs::transform_each::fields()), - "object.reflect" => ("reflect", json!({"axis": "vertical"})), + "object.reflect" => ("reflect", json!({"axis": "vertical", "angle": 90})), "object.shear" => ("shear", json!({"angle": 0, "axis": "horizontal"})), "path.average" => ("average", json!({"axis": "both"})), // Offset Path opens on the values it was last confirmed with.