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:
1 parent
de1479029d
commit
facff1050e
10 files changed
+319
-8
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "マウスホイールでカーソル位置を基準にズームします(オフの場合はビューの中心を基準にズームします)。",
|
||||
|
||||
@@ -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": "커서 위치로 확대/축소"
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user