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.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 13:48:13 -04:00
1 parent de1479029d
commit facff1050e
10 files changed
+319 -8

No files matched your search

@@ -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<SelInput> {
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<_>>(), 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<Sel> = 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<f64> = 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<String> = 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<Sel> = 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<f64> = 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<Sel> = 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:?}");
}