From c53e161e373cd007af1aae522b8341701ff3ad48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Thu, 15 Oct 2026 16:03:04 +0100 Subject: [PATCH] fix(G11): Reflect X/Y flip the correct axes (#1910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transform Each had Reflect X and Reflect Y swapped relative to Effect › Distort › Transform and the Transform panel. Properties flip buttons also used inverted tooltips. Align them so Horizontal is left-right and Vertical is top-bottom. Co-authored-by: Cursor --- crates/engine/src/cmd/menucmds.rs | 6 +++-- crates/engine/src/tests_menucmds.rs | 30 +++++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 8 +++---- docs/gaps.md | 4 ++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 0aa19b6ae..465e38851 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -76,7 +76,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), 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}", + "{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip left-right, as Reflect X in the Transform Each dialog), reflectY?: bool (flip top-bottom, as Reflect Y), 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 ), @@ -363,7 +363,9 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { k_rot = rot * (rng.next() * 2.0 - 1.0); } let o = vectorcraft_geom::reference_point(b, refi); - let refl = Affine::scale_non_uniform(if ry { -1.0 } else { 1.0 }, if rx { -1.0 } else { 1.0 }); + // Reflect X / Reflect Y match Object › Transform › Transform Each and Effect › Distort › Transform: + // Reflect X flips left-right (negate x), Reflect Y flips top-bottom (negate y). + let refl = Affine::scale_non_uniform(if rx { -1.0 } else { 1.0 }, if ry { -1.0 } else { 1.0 }); 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) { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index a6ae6ebe5..e1edfebf5 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -357,6 +357,36 @@ fn transform_each_random_is_deterministic_with_seed() { assert!(x.x0.abs() <= 50.0 && x.width() == 100.0); } +/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as +/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes). +#[test] +fn transform_each_reflect_x_flips_left_right_reflect_y_flips_top_bottom() { + let mut s = session(); + // A right-pointing triangle: tip at x=100, base on the left. + let id = + id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 50}, {"x": 0, "y": 100}], "closed": true})).unwrap()); + sel(&mut s, &[id]); + s.execute("object.transformEach", &json!({"reflectX": true})).unwrap(); + let tip_x = match &node(&s, id).kind { + // The tip is the only vertex at y=50; Reflect X about the centre maps (100, 50) → (0, 50). + NodeKind::Path { path, .. } => path.subpaths[0].anchors.iter().find(|a| (a.p.y - 50.0).abs() < 1e-6).map(|a| a.p.x).unwrap_or(f64::NAN), + _ => panic!("expected a path"), + }; + assert!((tip_x - 0.0).abs() < 1e-6, "reflectX must move the tip from x=100 to x=0, got {tip_x}"); + + s.execute("edit.undo", &json!({})).unwrap(); + // A down-pointing tip at (50, 100); Reflect Y about the centre (50, 50) maps it to (50, 0). + let id2 = + id_of(&s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 50, "y": 100}], "closed": true})).unwrap()); + sel(&mut s, &[id2]); + s.execute("object.transformEach", &json!({"reflectY": true})).unwrap(); + let tip_y = match &node(&s, id2).kind { + NodeKind::Path { path, .. } => path.subpaths[0].anchors.iter().find(|a| (a.p.x - 50.0).abs() < 1e-6).map(|a| a.p.y).unwrap_or(f64::NAN), + _ => panic!("expected a path"), + }; + assert!((tip_y - 0.0).abs() < 1e-6, "reflectY must move the tip from y=100 to y=0, got {tip_y}"); +} + /// Transform Each with its default values, or with any values that move nothing (both scales 100%, /// no move, rotation or reflection, Random or not), records no undo step and replies with the /// objects. With only a layer selected there is no object to transform: with Copy or without, it diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 775a5a171..4375e610a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -454,12 +454,12 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.rotate", json!({"angle": a, "absolute": true})).ok(); } ui.add_space(10.0); - // Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical - // line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom). - if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() { + // Same mapping as the Transform panel (#1910): Lucide flip-vertical-2 is the vertical + // mirror line (Flip Horizontal / left-right); flip-horizontal-2 is Flip Vertical. + if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Horizontal"), false, 24.0).clicked() { app.run("object.reflect", json!({"axis": "vertical"})).ok(); } - if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() { + if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Vertical"), false, 24.0).clicked() { app.run("object.reflect", json!({"axis": "horizontal"})).ok(); } // More Options: the Transform panel's Scale Corners and Scale Strokes & Effects, open diff --git a/docs/gaps.md b/docs/gaps.md index cc1a5782c..759a6fd7a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -151,6 +151,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall closed, the document left modified (#1865, #1685). - **Done:** the Shape Builder offers the empty areas closed shapes enclose as regions, such as the middle of four circles that touch, and merges them with the regions around them (#1691). Gap Detection is still missing. +- **Done (#1910):** Transform Each's Reflect X flips left-right and Reflect Y flips top-bottom (aligned with + Effect › Distort › Transform). Properties flip buttons use the same Flip Horizontal / Flip Vertical labels as + the Transform panel. - **Missing (noted for reporters, 2026-10-14):** deleting an interior anchor of an open path joins across the gap, where the documented behaviour removes the point and its connecting segments. Inside the estimate below. - **Missing:** Shape Builder misses planar regions and targets whole shapes (#937); Transform effect dialog's invisible checkboxes and missing options (#885); @@ -292,6 +295,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G11: Transform Each Reflect X/Y and Properties flip labels match left-right / top-bottom (#1910). Estimates unchanged. | | 2026-10-14 | minor | Quick Image Trace presets reveal the adjustment panel with Advanced open; Properties reopens it. Sliders keep their preset row until release so the re-trace is committed. UI regressions added; scores and remaining estimates unchanged. | | 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | | 2026-10-14 | minor | No entry closed. G8 and G11 record the later merges of 2026-10-14 (#1852, #1862–#1865, #1871–#1873, #1839, #1840, #1856); #1683 (Dim Images) and #1685 (failed-save dialog) are done by #1864 and #1865. Gaps noted for reporters: G1 Effect › Warp through Warp Options (#1760), Color Picker Tab order (#1692), aligning to a key anchor (#1636; the public Align page does not say how the key anchor is chosen); G7 custom toolbars (#1759), workspace panel widths (#1753); G11 Shape Builder regions between closed shapes (#1691), deleting an interior anchor of an open path; G13 Intertwine (#1763), gradient mesh from an embedded image (#1676), Image Trace radial gradients (draft #1849). Average and Join with a corner or smooth choice was already listed (G1). G12: a line two merges had left twice is there once, without Appearance of Black for output, which acts since #1750. Estimates unchanged |