From facff1050ed9ee337e8223ed0cbc69f856004ef5 Mon Sep 17 00:00:00 2001 From: William Dunn Date: Tue, 13 Oct 2026 13:48:13 -0400 Subject: [PATCH] Put Move/Copy Sketch Entities on the toolbar, behind a window with distances, an angle and a copy flag (#735) Sketch Move gets a toolbar placement in Sketch > Modify and a Move/Copy tool window with X and Y distances, an angle, an optional centre point and a Create Copy flag, replacing the context-menu route that ran the command with no parameters. --- crates/engine/src/cmd/sketch_modify.rs | 2 + crates/ui-egui/src/canvas.rs | 20 ++- crates/ui-egui/src/locales/de.json | 4 + crates/ui-egui/src/locales/en.json | 4 + crates/ui-egui/src/locales/ja.json | 4 + crates/ui-egui/src/locales/ko.json | 4 + crates/ui-egui/src/sketch_modify_ui.rs | 66 +++++++- crates/ui-egui/src/sketch_modify_ui_tests.rs | 156 ++++++++++++++++++ .../tests/scenarios/sketch_move_window.json | 65 ++++++++ docs/features.md | 2 +- 10 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/sketch_move_window.json diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 37bfc301..08f9aaf2 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -68,6 +68,8 @@ pub static COMMANDS: &[CommandSpec] = &[ .enabled(in_sketch) .params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"), CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy) + .at("SKETCH", "MODIFY") + .icon("move") .enabled(in_sketch) .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())), CommandSpec::new("sketch.mirror", "Mirror", mirror) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 363c89c3..a73a74f4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -85,7 +85,14 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), Kind::Plastic(_) => (at, normal), Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)), - // Already computed above, per picked curve; keep it rather than the default below. + // A sketch move's X Distance grows along the sketch's own x axis, so its drag arrow + // lies in the sketch plane. Scale has no arrow: a factor is not a length, and the + // square handle it wants is its own change. + Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. }) => { + (at, app.session.active_sketch.and_then(|id| st.sketch(id)).and_then(|ss| ss.plane.x.normalized())) + } + // Offset's arrow is already computed above, per picked curve; keep it rather than the + // default below. Kind::SketchModify(_) => (at, normal), _ => (at, None), }) @@ -282,8 +289,15 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }; let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. }); // Extrudes and moves go either way; radii and thicknesses stay positive. - let signed = - matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. }); + let signed = matches!( + d.kind, + Kind::Extrude { .. } + | Kind::Move { .. } + | Kind::Section { .. } + | Kind::OffsetPlane { .. } + | Kind::OffsetFaces { .. } + | Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. }) + ); let focus = std::mem::take(&mut d.focus); let half_height = app.cam.half_height(); let axis = revolve_axis(app, d); diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index d5fbf341..70c9e84d 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -45,6 +45,7 @@ "ui.break": "Unterbrechen", "ui.cancel": "Abbrechen", "ui.canvases": "Kanvas", + "ui.center": "Mittelpunkt", "ui.centerline": "Mittellinie", "ui.chain": "Kette", "ui.chamfer": "Abschrägung", @@ -146,6 +147,7 @@ "ui.heading_modify": "ÄNDERN", "ui.heading_move": "VERSCHIEBEN", "ui.heading_offset": "ABSTAND", + "ui.heading_move_copy": "VERSCHIEBEN/KOPIEREN", "ui.heading_offset_plane": "ABSTANDSEBENE", "ui.heading_ok": "OK", "ui.heading_parameters": "PARAMETER", @@ -312,5 +314,7 @@ "ui.view_centre": "Ansichtszentrum", "ui.visual_style": "Ansichtsstil", "ui.workspace": "Arbeitsbereich", + "ui.x_distance": "X-Abstand", + "ui.y_distance": "Y-Abstand", "ui.zoom_to_the_cursor": "Zum Cursor zoomen" } diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index ed5e65c0..3788b3a1 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -303,6 +303,7 @@ "ui.break": "Break", "ui.cancel": "Cancel", "ui.canvases": "Canvases", + "ui.center": "Center", "ui.centerline": "Centerline", "ui.chain": "Chain", "ui.chamfer": "Chamfer", @@ -416,6 +417,7 @@ "ui.heading_modify": "MODIFY", "ui.heading_move": "MOVE", "ui.heading_offset": "OFFSET", + "ui.heading_move_copy": "MOVE/COPY", "ui.heading_offset_plane": "OFFSET PLANE", "ui.heading_ok": "OK", "ui.heading_parameters": "PARAMETERS", @@ -606,6 +608,8 @@ "ui.visual_style": "Visual style", "ui.visual_wireframe": "Wireframe", "ui.workspace": "workspace", + "ui.x_distance": "X Distance", + "ui.y_distance": "Y Distance", "ui.y_up": "Y up", "ui.z_up": "Z up", "ui.zoom_to_cursor_tip": "The wheel zooms toward the point under the cursor (off: toward the view's centre)", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index 41991a4d..cc03dd56 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -303,6 +303,7 @@ "ui.break": "分割", "ui.cancel": "キャンセル", "ui.canvases": "キャンバス", + "ui.center": "中心", "ui.centerline": "中心線", "ui.chain": "チェーン", "ui.chamfer": "面取り", @@ -416,6 +417,7 @@ "ui.heading_modify": "修正", "ui.heading_move": "移動", "ui.heading_offset": "オフセット", + "ui.heading_move_copy": "移動/コピー", "ui.heading_offset_plane": "オフセット平面", "ui.heading_ok": "OK", "ui.heading_parameters": "パラメータ", @@ -606,6 +608,8 @@ "ui.visual_style": "表示スタイル", "ui.visual_wireframe": "ワイヤーフレーム", "ui.workspace": "ワークスペース", + "ui.x_distance": "X 距離", + "ui.y_distance": "Y 距離", "ui.y_up": "Y軸上向き", "ui.z_up": "Z軸上向き", "ui.zoom_to_cursor_tip": "マウスホイールでカーソル位置を基準にズームします(オフの場合はビューの中心を基準にズームします)。", diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index fd835f9b..ea6f1d06 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -45,6 +45,7 @@ "ui.break": "끊기", "ui.cancel": "취소", "ui.canvases": "캔버스", + "ui.center": "중심", "ui.centerline": "중심선", "ui.chain": "체인", "ui.chamfer": "모따기", @@ -146,6 +147,7 @@ "ui.heading_modify": "수정", "ui.heading_move": "이동", "ui.heading_offset": "오프셋", + "ui.heading_move_copy": "이동/복사", "ui.heading_offset_plane": "오프셋 평면", "ui.heading_ok": "확인", "ui.heading_parameters": "매개변수", @@ -312,5 +314,7 @@ "ui.view_centre": "뷰 중심", "ui.visual_style": "시각 스타일", "ui.workspace": "작업 공간", + "ui.x_distance": "X 거리", + "ui.y_distance": "Y 거리", "ui.zoom_to_the_cursor": "커서 위치로 확대/축소" } diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index cdb1a104..82db721b 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -6,8 +6,11 @@ //! issue #162 item 1: "Offset does not create a constraint, the size of the offset seems //! random"). Mirror joins them here, which ran as a bare click flow: entities until a //! right-click, then one more click for the line, with nothing on screen saying what was -//! picked. `sketch.fillet`, the three `sketch.chamfer.*` commands, the sketch patterns and -//! `sketch.move` are the same gap and join this module later, each its own change. +//! picked. Move joins them too, which had no toolbar placement, no tool and no dialog: its only +//! route into the UI was the sketch context menu, and that went through the same +//! empty-parameter fallback and reported an engine error. `sketch.fillet`, the three +//! `sketch.chamfer.*` commands and the sketch patterns are the same gap and join this module +//! later, each its own change. use serde_json::{Map, Value, json}; use solidcraft_engine::Sel; @@ -32,6 +35,10 @@ pub enum Mod { /// its own, so the window's whole job is to show what is picked and let a pick be changed /// before anything is committed. Mirror, + /// `sketch.move`: how far the picked entities move along the sketch's own axes, how far they + /// turn about the centre, and whether the originals stay behind. The command already + /// carries the copy flag, so the Create Copy row needs no engine change. + Move { x: String, y: String, angle: String, copy: bool }, } impl Mod { @@ -40,6 +47,7 @@ impl Mod { Mod::Scale { .. } => "SKETCH SCALE", Mod::Offset { .. } => "OFFSET", Mod::Mirror => "MIRROR", + Mod::Move { .. } => "MOVE/COPY", } } @@ -48,6 +56,7 @@ impl Mod { match self { Mod::Scale { factor } => Some(("Factor", ValueKind::Unitless, factor)), Mod::Offset { distance, .. } => Some(("Distance", ValueKind::Length, distance)), + Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)), Mod::Mirror => None, } } @@ -59,6 +68,7 @@ fn inputs(k: &Mod) -> Vec { Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)], // AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`). Mod::Mirror => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Mirror line", AXES, false)], + Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)], } } @@ -68,6 +78,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "sketch.scale" => Mod::Scale { factor: "2".into() }, "sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false }, "sketch.mirror" => Mod::Mirror, + "sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false }, _ => return None, }; let i = inputs(&k); @@ -96,6 +107,20 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { } // Mirror has no value of its own: its inputs are the whole window. Mod::Mirror => {} + Mod::Move { x, y, angle, copy } => { + row_label(ui, "X Distance"); + enter |= field(ui, x); + ui.end_row(); + row_label(ui, "Y Distance"); + enter |= field(ui, y); + ui.end_row(); + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + row_label(ui, "Create Copy"); + check(ui, copy); + ui.end_row(); + } } enter } @@ -142,6 +167,22 @@ fn factor(app: &SolveApp, text: &str) -> Result { Ok(v) } +/// A typed field as the command wants it: evaluated against the document's parameters, so an +/// expression works, with a bare number read in the design's own units. An empty field is zero, +/// the way an untouched row should leave the geometry where it is. +fn value(app: &SolveApp, text: &str, kind: ValueKind, label: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Ok(0.0); + } + let doc = &app.session.doc; + let v = doc.eval(&doc.with_design_unit(text, kind), kind).map_err(|e| format!("{label}: {e}"))?; + if !v.is_finite() { + return Err(format!("{label} must be a number")); + } + Ok(v) +} + /// A typed distance as the command wants it: the field evaluated against the document's /// parameters, with the sign reversed when Flip is set. A bare number means the design's units, /// as a typed sketch value does everywhere else. @@ -155,8 +196,8 @@ fn signed_length(app: &SolveApp, text: &str, flip: bool) -> Result } /// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for -/// its factor and `sketch.offset` one for its distance, not an expression string: neither has a -/// model parameter to carry one, unlike `sketch.fillet`'s radius. +/// its factor, `sketch.offset` one for its distance and `sketch.move` its distances and angle, +/// not expression strings: none has a model parameter to carry one, unlike `sketch.fillet`'s radius. pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map) -> Result, String> { let (cmd, mut params) = match k { Mod::Scale { factor: text } => { @@ -196,6 +237,23 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map { + let entities = entity_ids(inputs, 0); + if entities.is_empty() { + return Err("Select the sketch curves or points to move first".into()); + } + let dx = value(app, x, ValueKind::Length, "X Distance")?; + let dy = value(app, y, ValueKind::Length, "Y Distance")?; + let deg = value(app, angle, ValueKind::Angle, "Angle")?.to_degrees(); + if dx.abs() < 1e-9 && dy.abs() < 1e-9 && deg.abs() < 1e-9 { + return Err("Type a distance or an angle to move by".into()); + } + let mut p = json!({"entities": entities, "translate": [dx, dy], "angle": deg, "copy": copy}); + if let Some(center) = items(inputs, 1).first().and_then(point_id) { + p["center"] = json!(center); + } + ("sketch.move", p) + } }; if let Value::Object(m) = &mut params { for (key, value) in extra { diff --git a/crates/ui-egui/src/sketch_modify_ui_tests.rs b/crates/ui-egui/src/sketch_modify_ui_tests.rs index 2e5e592a..14bffb16 100644 --- a/crates/ui-egui/src/sketch_modify_ui_tests.rs +++ b/crates/ui-egui/src/sketch_modify_ui_tests.rs @@ -2,6 +2,9 @@ //! dialog, so a press ran `sketch.scale` with no parameters and the engine refused it. Mirror //! ran as a click flow that collected entities until a right-click, took the next click as the //! mirror line and committed at once, with nothing on screen saying what was picked. +//! `sketch.move` had no toolbar placement, no tool and no dialog either. Its only route into +//! the UI was the sketch context menu, and that went through the same empty-parameter +//! fallback, so the item reported an engine error. use serde_json::json; use solidcraft_engine::Sel; @@ -218,6 +221,141 @@ fn the_window_reflects_the_picked_circle_across_the_line() { assert!((*r - 3.0).abs() < 1e-6, "{r}"); } +fn move_inputs(h: &Harness) -> Vec { + let mut v = vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)]; + v[0].items = curve_ids(h).iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(); + v +} + +/// A fresh Move window's values, with one field replaced. Functional record update (`..base`) +/// is a struct feature, so an enum variant has to spell every field out. +fn moved(x: &str, y: &str, angle: &str) -> Mod { + Mod::Move { x: x.into(), y: y.into(), angle: angle.into(), copy: false } +} + +#[test] +fn the_command_now_has_a_toolbar_placement_and_opens_a_window() { + let spec = solidcraft_engine::find_command("sketch.move").expect("sketch.move is registered"); + assert_eq!((spec.tab, spec.panel), ("SKETCH", "MODIFY")); + assert_eq!(spec.icon, "move"); + + let mut h = rectangle(); + let before = curve_ids(&h).len(); + h.step(&json!({"start": "sketch.move"})).unwrap(); + let d = h.app.dialog.as_ref().expect("sketch.move opens a tool window"); + assert!(matches!(d.kind, Kind::SketchModify(Mod::Move { .. })), "{:?}", d.kind); + assert_eq!(d.inputs.iter().map(|i| i.label).collect::>(), vec!["Entities", "Center"]); + // Opening the window commits nothing, and the context menu's engine error is gone. + assert_eq!(curve_ids(&h).len(), before); + assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{:?}", h.app.status); +} + +#[test] +fn the_context_menu_item_fills_the_window_from_the_selection() { + let mut h = rectangle(); + let ids = curve_ids(&h); + h.app.run("select.set", json!({"items": [{"type": "sketch_curve", "id": ids[0]}]})).unwrap(); + h.step(&json!({"start": "sketch.move"})).unwrap(); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[0].items, vec![Sel::SketchCurve { id: ids[0].clone() }]); +} + +#[test] +fn the_typed_distances_and_angle_reach_the_command() { + let h = rectangle(); + let inputs = move_inputs(&h); + let k = Mod::Move { x: "10 mm".into(), y: "-4 mm".into(), angle: "90 deg".into(), copy: false }; + let out = commands(&h.app, &k, &inputs, &serde_json::Map::new()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].0, "sketch.move"); + assert_eq!(out[0].1["translate"], json!([10.0, -4.0])); + assert!((out[0].1["angle"].as_f64().unwrap() - 90.0).abs() < 1e-9, "{}", out[0].1["angle"]); + assert_eq!(out[0].1["copy"], json!(false)); + // No centre picked: the command's own default, the sketch origin, stands. + assert!(out[0].1.get("center").is_none(), "{}", out[0].1); +} + +#[test] +fn an_expression_and_a_bare_angle_are_evaluated() { + let mut h = rectangle(); + h.app.run("parameters.add", json!({"name": "step", "expression": "7 mm", "unit": "mm"})).unwrap(); + let inputs = move_inputs(&h); + let k = Mod::Move { x: "step * 2".into(), y: String::new(), angle: "45".into(), copy: false }; + let out = commands(&h.app, &k, &inputs, &serde_json::Map::new()).unwrap(); + assert_eq!(out[0].1["translate"], json!([14.0, 0.0])); + assert!((out[0].1["angle"].as_f64().unwrap() - 45.0).abs() < 1e-9, "{}", out[0].1["angle"]); +} + +#[test] +fn an_empty_window_or_a_zero_move_is_an_error_instead_of_a_command() { + let h = rectangle(); + let empty = vec![SelInput::new("Entities", CURVES | POINTS, true)]; + assert!(commands(&h.app, &moved("5 mm", "0 mm", "0 deg"), &empty, &serde_json::Map::new()).is_err()); + // Nothing typed: the dialog says so, and no move of zero reaches the command. + assert!(commands(&h.app, &moved("0 mm", "0 mm", "0 deg"), &move_inputs(&h), &serde_json::Map::new()).is_err()); + assert!(commands(&h.app, &moved("nonsense", "0 mm", "0 deg"), &move_inputs(&h), &serde_json::Map::new()).is_err()); +} + +#[test] +fn the_window_moves_the_rectangle_and_the_copy_row_leaves_the_original_behind() { + let mut h = rectangle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start": "sketch.move"})).unwrap(); + // Read the ids before the dialog is borrowed mutably: `curve_ids` borrows the harness. + let picks: Vec = curve_ids(&h).iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(); + { + let d = h.app.dialog.as_mut().unwrap(); + d.inputs[0].items = picks; + d.kind = Kind::SketchModify(Mod::Move { x: "30 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: true }); + } + // A frame with the picks in place: the canvas value box takes focus on the frame after a + // pick, and Enter only applies once it has had it. + h.frames(2); + h.step(&json!({"key": "Enter"})).unwrap(); + h.settle(); + assert!(h.app.dialog.is_none(), "Enter applies and closes the window"); + let st = h.app.session.model.state(); + let sk = &st.sketch(sid).unwrap().sketch; + assert_eq!(sk.curves.len(), 8, "a copy keeps the original: {:?}", h.app.status); + let xs: Vec = sk.points.iter().map(|p| p.pos.x).collect(); + assert!(xs.iter().any(|x| x.abs() < 1e-6), "the original stays at x = 0: {xs:?}"); + assert!(xs.iter().any(|x| (x - 50.0).abs() < 1e-6), "the copy's far corner is at x = 50: {xs:?}"); +} + +#[test] +fn a_plain_move_takes_the_geometry_with_it() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + // Clear of the sketch origin. A rectangle drawn from the origin keeps a corner tied to it, + // and the solver pulls that corner back after the move, which measures the solver rather + // than this window. + let made = h.app.run("sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 20]})).unwrap(); + let corners: Vec = made["corners"].as_object().into_iter().flatten().filter_map(|(_, v)| v.as_str().map(str::to_string)).collect(); + assert_eq!(corners.len(), 4, "{made}"); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start": "sketch.move"})).unwrap(); + // Read the ids before the dialog is borrowed mutably: `curve_ids` borrows the harness. + let picks: Vec = curve_ids(&h).iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(); + { + let d = h.app.dialog.as_mut().unwrap(); + d.inputs[0].items = picks; + d.kind = Kind::SketchModify(Mod::Move { x: "0 mm".into(), y: "5 mm".into(), angle: "0 deg".into(), copy: false }); + } + // A frame with the picks in place: the canvas value box takes focus on the frame after a + // pick, and Enter only applies once it has had it. + h.frames(2); + h.step(&json!({"key": "Enter"})).unwrap(); + h.settle(); + let st = h.app.session.model.state(); + let sk = &st.sketch(sid).unwrap().sketch; + assert_eq!(sk.curves.len(), 4, "a plain move makes nothing new: {:?}", h.app.status); + // The rectangle's own corners, not the sketch origin, which no move touches. + let ys: Vec = sk.points.iter().filter(|p| corners.contains(&p.id)).map(|p| p.pos.y).collect(); + assert_eq!(ys.len(), 4, "{ys:?}"); + assert!(ys.iter().all(|y| (*y - 15.0).abs() < 1e-6 || (*y - 25.0).abs() < 1e-6), "the rectangle moved up by 5 mm: {ys:?}"); +} + #[test] fn escape_leaves_the_sketch_exactly_as_it_was() { let mut h = line_and_circle(); @@ -239,3 +377,21 @@ fn escape_leaves_the_sketch_exactly_as_it_was() { assert_eq!(h.app.session.undo.len(), undo); assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.constraints.len(), constraints); } + +#[test] +fn the_x_distance_gets_a_drag_arrow_along_the_sketch_x_axis() { + let mut h = rectangle(); + h.step(&json!({"start": "sketch.move"})).unwrap(); + // Read the ids before the dialog is borrowed mutably: `curve_ids` borrows the harness. + let picks: Vec = curve_ids(&h).iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(); + { + let d = h.app.dialog.as_mut().unwrap(); + d.inputs[0].items = picks; + } + let sid = h.app.session.active_sketch.unwrap(); + let x = h.app.session.world_state().sketch(sid).unwrap().plane.x; + let d = h.app.dialog.as_ref().unwrap(); + let (_, dir) = crate::canvas::anchor(&h.app, d).expect("a picked sketch curve anchors the manipulator"); + let dir = dir.expect("a sketch move's X Distance has a direction to drag along"); + assert!((dir - x).len() < 1e-9, "{dir:?} is not the sketch x axis {x:?}"); +} diff --git a/crates/ui-egui/tests/scenarios/sketch_move_window.json b/crates/ui-egui/tests/scenarios/sketch_move_window.json new file mode 100644 index 00000000..2dfb07a2 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_move_window.json @@ -0,0 +1,65 @@ +[ + {"note": "sketch.move had no toolbar placement, no tool and no dialog. Its only route into the UI was the sketch context menu, which went through the empty-parameter fallback and reported an engine error. It now sits on SKETCH MODIFY and opens a tool window carrying Entities, a Center, X Distance, Y Distance, Angle and Create Copy."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"expect": {"sketching": true}}, + {"start": "sketch.rectangle.two_point"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [20, 10]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 4}, "errors": 0}}, + + {"note": "The command opens a window and commits nothing."}, + {"start": "sketch.move"}, + {"expect": {"dialog": "SketchModify(Move", "sketch_curves": {"own": 4}, "errors": 0}}, + {"click": {"sketch": [10, 0]}}, + {"expect": {"dialog": "SketchModify(Move", "sketch_curves": {"own": 4}, "errors": 0}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 4}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["profiles", 0, "area_mm2"], "value": 200}}}, + + {"note": "Create Copy ticked: the rectangle is copied 30 mm along X, so the sketch holds eight curves and two profiles."}, + {"start": "sketch.move"}, + {"click": {"sketch": [10, 0]}}, + {"click": {"sketch": [20, 5]}}, + {"click": {"sketch": [10, 10]}}, + {"click": {"sketch": [0, 5]}}, + {"click": {"handle": "dialog:Create Copy"}}, + {"click": {"handle": "dialog:X Distance"}}, + {"key": "A", "cmd": true}, + {"text": "30"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["profiles", 0, "area_mm2"], "value": 200}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["profiles", 1, "area_mm2"], "value": 200}}}, + {"shot": "sketch_move_copy_window"}, + + {"note": "Create Copy cleared (the default): a plain move takes the copy with it and makes nothing new. Moving it 20 mm along Y leaves eight curves."}, + {"start": "sketch.move"}, + {"click": {"sketch": [40, 0]}}, + {"click": {"sketch": [50, 5]}}, + {"click": {"sketch": [40, 10]}}, + {"click": {"sketch": [30, 5]}}, + {"click": {"handle": "dialog:Y Distance"}}, + {"key": "A", "cmd": true}, + {"text": "20"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["profiles", 1, "area_mm2"], "value": 200}}}, + {"shot": "sketch_move_window"}, + + {"note": "The X Distance also has an on-canvas drag arrow, along the sketch's own x axis, with its value box beside it. A factor has no length to drag, so Scale gets no arrow."}, + {"start": "sketch.move"}, + {"click": {"sketch": [40, 20]}}, + {"expect": {"shown_handles": ["arrow"]}}, + {"drag": [{"handle": "arrow"}, {"sketch": [70, 20]}]}, + {"expect": {"dialog": "SketchModify(Move", "errors": 0}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}}, + + {"note": "Starting the command again and pressing Escape leaves the document and the undo history untouched."}, + {"start": "sketch.move"}, + {"expect": {"dialog": "SketchModify(Move"}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0}} +] diff --git a/docs/features.md b/docs/features.md index 62fd4be7..f8f529f0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -88,7 +88,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Extend | `sketch.extend` | SKETCH › MODIFY | | Break | `sketch.break` | SKETCH › MODIFY | | Sketch Scale | `sketch.scale` | SKETCH › MODIFY | -| Move/Copy Sketch Entities | `sketch.move` | – | +| Move/Copy Sketch Entities | `sketch.move` | SKETCH › MODIFY | | Mirror | `sketch.mirror` | SKETCH › CREATE | | Circular Pattern | `sketch.pattern.circular` | SKETCH › CREATE | | Rectangular Pattern | `sketch.pattern.rectangular` | SKETCH › CREATE |