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
View File
@@ -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)
+17 -3
View File
@@ -85,7 +85,14 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
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);
+4
View File
@@ -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"
}
+4
View File
@@ -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)",
+4
View File
@@ -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": "マウスホイールでカーソル位置を基準にズームします(オフの場合はビューの中心を基準にズームします)。",
+4
View File
@@ -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": "커서 위치로 확대/축소"
}
+62 -4
View File
@@ -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<SelInput> {
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<SelInput>)> {
"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<f64, String> {
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<f64, String> {
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<f64, String>
}
/// 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<String, Value>) -> Result<Vec<(String, Value)>, 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<String
}
("sketch.mirror", json!({"entities": entities, "line": line}))
}
Mod::Move { x, y, angle, copy } => {
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 {
@@ -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:?}");
}
@@ -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}}
]
+1 -1
View File
@@ -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 |