From 3ea8562c5e07c299c574521896744e97c8b737dd Mon Sep 17 00:00:00 2001 From: William Dunn Date: Tue, 13 Oct 2026 14:54:48 -0400 Subject: [PATCH] A tool window for the rectangular sketch pattern (#799) sketch.pattern.rectangular gets a tool window with both quantities and spacings and an optional direction, instead of a click flow with a fixed count. Co-Authored-By: Claude Opus 5 (1M context) --- crates/ui-egui/src/canvas.rs | 7 +- 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 | 112 ++++++++++++++- crates/ui-egui/src/sketch_modify_ui_tests.rs | 128 +++++++++++++++++- crates/ui-egui/src/sketch_tools.rs | 31 ++--- .../scenarios/sketch_pattern_rect_window.json | 89 ++++++++++++ 9 files changed, 353 insertions(+), 30 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/sketch_pattern_rect_window.json diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f04d2465..ff9784b2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -101,9 +101,10 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { 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), + // The rectangular pattern's spacing grows along the direction it was given, which the + // module works out per variant. Offset's arrow is already computed above, per picked + // curve, and `arrow` returns None for it so that one stands. + Kind::SketchModify(_) => (at, crate::sketch_modify_ui::arrow(app, d).or(normal)), _ => (at, None), }) } diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index d7e7962f..1d045aa6 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -247,6 +247,8 @@ "ui.profile_plane": "Profilebene", "ui.profiles": "Profile", "ui.project": "Projizieren", + "ui.quantity": "Anzahl", + "ui.quantity_2": "Anzahl 2", "ui.quit": "Beenden", "ui.radius": "Radius", "ui.radius_type": "Radius-Typ", @@ -286,6 +288,8 @@ "ui.slot": "Langloch", "ui.smooth": "Glatt", "ui.snap": "Einrasten", + "ui.spacing": "Abstand", + "ui.spacing_2": "Abstand 2", "ui.sphere": "Sphäre", "ui.spline": "Spline", "ui.split_body": "Körper trennen", diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index 7c2ccc2a..cf0fc080 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -525,6 +525,8 @@ "ui.profiles": "Profiles", "ui.project": "Project", "ui.project_face_edges_sketch": "Project face edges into new sketches", + "ui.quantity": "Quantity", + "ui.quantity_2": "Quantity 2", "ui.quit": "Quit", "ui.radial_context_menu": "Radial context menu", "ui.radius": "Radius", @@ -573,6 +575,8 @@ "ui.snap": "Snap", "ui.space_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.", "ui.space_mouse_navigate": "Navigate with SpaceNavigator / SpaceMouse", + "ui.spacing": "Spacing", + "ui.spacing_2": "Spacing 2", "ui.sphere": "Sphere", "ui.spline": "Spline", "ui.split_body": "Split Body", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index 962f9ac1..dc962a10 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -525,6 +525,8 @@ "ui.profiles": "プロファイル", "ui.project": "投影", "ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影", + "ui.quantity": "数量", + "ui.quantity_2": "数量 2", "ui.quit": "終了", "ui.radial_context_menu": "パイ メニュー", "ui.radius": "半径", @@ -573,6 +575,8 @@ "ui.snap": "スナップ", "ui.space_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)", "ui.space_mouse_navigate": "SpaceNavigator / SpaceMouseで視点を操作", + "ui.spacing": "間隔", + "ui.spacing_2": "間隔 2", "ui.sphere": "球", "ui.spline": "スプライン", "ui.split_body": "ボディを分割", diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index 2f050718..2d5aab18 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -247,6 +247,8 @@ "ui.profile_plane": "프로파일 평면", "ui.profiles": "프로파일", "ui.project": "투영", + "ui.quantity": "수량", + "ui.quantity_2": "수량 2", "ui.quit": "종료", "ui.radius": "반지름", "ui.radius_type": "반지름 유형", @@ -286,6 +288,8 @@ "ui.slot": "슬롯", "ui.smooth": "부드럽게", "ui.snap": "스냅", + "ui.spacing": "간각", + "ui.spacing_2": "간각 2", "ui.sphere": "구", "ui.spline": "스플라인", "ui.split_body": "바디 분할", diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 0831f4d7..98e0e5b7 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -14,8 +14,10 @@ //! size guessed at 20 percent of the shorter leg and then opened a bare parameter editor, so //! the design changed before any number was asked for. `sketch.chamfer.distance_angle` sent a //! hard-coded 45 degrees and `sketch.chamfer.two_distance` forced its second distance equal to -//! the first, leaving both values unreachable from the UI. The sketch patterns are the same -//! gap and join this module later, each its own change. +//! the first, leaving both values unreachable from the UI. The rectangular pattern joins them +//! too, whose click tool sent a hard-coded count of 3 and could reach neither the command's +//! second count nor either spacing, so a user who wanted five instances got three and deleted +//! two. The circular pattern is the same gap and joins this module later. //! //! The Chamfer Type choice is a row of icon buttons rather than a drop-down: the modes differ //! in shape, and a picture of each shape names it better than a line of text. @@ -24,15 +26,19 @@ use egui::Pos2; use serde_json::{Map, Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::doc::expr::Kind as ValueKind; +use solidcraft_engine::geom::{Vec2, Vec3}; use crate::SolveApp; -use crate::dialogs::{CHAMFER_TYPES, Kind, check, field, icon_choice, row_label}; +use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label}; use crate::selection::{AXES, CURVES, POINTS, SelInput}; use crate::viewport::{Hit, Proj}; /// The three chamfer types, drawn as the cut each one makes, in `CHAMFER_TYPES` order. const CHAMFER_TYPE_ICONS: [&str; 3] = ["sketch_chamfer_equal", "sketch_chamfer_two", "sketch_chamfer_angle"]; +/// The engine's own cap on the copies one sketch pattern may make. +const MAX_COPIES: f64 = 500.0; + /// A sketch modify tool window's values. #[derive(Clone, Debug, PartialEq)] pub enum Mod { @@ -52,6 +58,9 @@ pub enum Mod { /// 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 }, + /// `sketch.pattern.rectangular`: how many instances along the picked direction and how far + /// apart, then the same along the perpendicular direction the command derives from it. + PatternRect { count: String, spacing: String, count2: String, spacing2: String }, /// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a /// fillet and a chamfer and never changes while the window is open; `ctype` chooses which /// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and @@ -68,6 +77,7 @@ impl Mod { Mod::Offset { .. } => "OFFSET", Mod::Mirror => "MIRROR", Mod::Move { .. } => "MOVE/COPY", + Mod::PatternRect { .. } => "RECTANGULAR PATTERN", Mod::Corner { chamfer: false, .. } => "FILLET", Mod::Corner { .. } => "CHAMFER", } @@ -80,6 +90,7 @@ impl Mod { Mod::Offset { distance, .. } => Some(("Distance", ValueKind::Length, distance)), Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)), Mod::Mirror => None, + Mod::PatternRect { spacing, .. } => Some(("Spacing", ValueKind::Length, spacing)), Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)), Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)), } @@ -93,6 +104,9 @@ fn inputs(k: &Mod) -> Vec { // 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)], + // The direction is optional: `sketch.pattern.rectangular` documents sketch x as its + // default, the way the engine reads it. + Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Direction", AXES, false)], // A fillet rounds any number of corners to one radius (`points`). Each chamfer command // cuts one corner, so its input holds one. Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", POINTS, true)], @@ -115,6 +129,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "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 }, + "sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() }, "sketch.fillet" => corner(false, 0), "sketch.chamfer.equal_distance" => corner(true, 0), "sketch.chamfer.two_distance" => corner(true, 1), @@ -161,6 +176,13 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { check(ui, copy); ui.end_row(); } + Mod::PatternRect { count, spacing, count2, spacing2 } => { + for (label, value) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] { + row_label(ui, label); + enter |= field(ui, value); + ui.end_row(); + } + } Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => { if *chamfer { row_label(ui, "Chamfer Type"); @@ -191,6 +213,36 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { enter } +/// The in-plane direction a picked origin axis or straight sketch curve gives the active +/// sketch, as `sketch.pattern.rectangular` reads its `dir`. None when there is no active +/// sketch, or when the axis stands perpendicular to the sketch plane and so has no direction +/// in it. +fn sketch_dir(app: &SolveApp, sel: &Sel) -> Option { + let dir = crate::dialogs::axis_of(app, sel)?.1; + let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane; + Vec2::new(dir.dot(plane.x), dir.dot(plane.y)).normalized() +} + +/// The world direction a sketch modify window's dragged value grows along, for the viewport's +/// value box and its drag arrow (the same machinery `dialogs_sheet::arrow` feeds). The +/// rectangular pattern's spacing follows the picked direction, or the sketch's own x axis, which +/// is what the command uses when nothing is picked. Every other variant keeps the arrow +/// `canvas::anchor` already works out for it. +pub fn arrow(app: &SolveApp, d: &Dialog) -> Option { + let Kind::SketchModify(k) = &d.kind else { return None }; + match k { + Mod::PatternRect { .. } => { + let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane; + let dir = match d.inputs.get(1).and_then(|i| i.items.first()) { + Some(sel) => sketch_dir(app, sel)?, + None => Vec2::X, + }; + Some(plane.dir_to_world(dir)) + } + _ => None, + } +} + /// What the first value means for this corner type. fn size_label(chamfer: bool, ctype: usize) -> &'static str { if !chamfer { @@ -287,6 +339,33 @@ fn signed_length(app: &SolveApp, text: &str, flip: bool) -> Result Ok(if flip { -v } else { v }) } +/// A typed count as the command wants it: the field evaluated against the document's +/// parameters, then a whole number the engine accepts. +/// +/// The sketch patterns read `count` and `angle` with `num`, which takes a JSON number and +/// nothing else. An expression string would be refused outright for `count`, and silently +/// replaced by the 360 degree default for `angle`. That silent substitution is the defect +/// upstream #218 reports for `solid.pattern.circular`'s axis origin. The window therefore +/// evaluates every value here and sends numbers. +fn whole(app: &SolveApp, label: &str, text: &str) -> Result { + let v = app.session.doc.eval(text.trim(), ValueKind::Unitless).map_err(|e| format!("{label}: {e}"))?; + if !v.is_finite() || !(1.0..=MAX_COPIES).contains(&v) || (v - v.round()).abs() > 1e-9 { + return Err(format!("{label} must be a whole number from 1 to {MAX_COPIES:.0}")); + } + Ok(v.round() as u64) +} + +/// A typed length as the command wants it, in millimetres. A bare number means the design's +/// units, as a typed sketch value does everywhere else. `value` above reads an empty row as +/// zero, which a Move window wants and a pattern spacing does not. +fn length(app: &SolveApp, label: &str, text: &str) -> Result { + let doc = &app.session.doc; + let v = doc.eval(&doc.with_design_unit(text.trim(), ValueKind::Length), ValueKind::Length).map_err(|e| format!("{label}: {e}"))?; + if !v.is_finite() { + return Err(format!("{label} must be a number")); + } + Ok(v) +} /// Corner point ids picked in input `i`, within the active sketch. A `POINTS` pick is stored as /// ":"; a point selected without the window carries the bare id. fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec { @@ -393,6 +472,33 @@ 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 pattern first".into()); + } + let n1 = whole(app, "Quantity", count)?; + let s1 = length(app, "Spacing", spacing)?; + let n2 = whole(app, "Quantity 2", count2)?; + let s2 = length(app, "Spacing 2", spacing2)?; + if n1 > 1 && s1.abs() <= 1e-9 { + return Err("Spacing must be a non-zero number".into()); + } + if n2 > 1 && s2.abs() <= 1e-9 { + return Err("Spacing 2 must be a non-zero number".into()); + } + if n1.saturating_mul(n2) > MAX_COPIES as u64 { + return Err(format!("Quantity and Quantity 2 together must make at most {MAX_COPIES:.0} instances")); + } + let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2}); + // A picked direction that has no component in the sketch plane is reported rather + // than quietly replaced by the default. + if let Some(sel) = inputs.get(1).and_then(|i| i.items.first()) { + let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?; + p["dir"] = json!([d.x, d.y]); + } + ("sketch.pattern.rectangular", p) + } Mod::Corner { chamfer: false, size, .. } => { let corners = corner_ids(app, inputs, 0); if corners.is_empty() { diff --git a/crates/ui-egui/src/sketch_modify_ui_tests.rs b/crates/ui-egui/src/sketch_modify_ui_tests.rs index 14bffb16..c0ce3744 100644 --- a/crates/ui-egui/src/sketch_modify_ui_tests.rs +++ b/crates/ui-egui/src/sketch_modify_ui_tests.rs @@ -4,12 +4,14 @@ //! 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. +//! fallback, so the item reported an engine error. The rectangular pattern's click tool sent +//! a hard-coded count of 3 and could reach neither the command's second count nor either +//! spacing. use serde_json::json; use solidcraft_engine::Sel; -use crate::dialogs::Kind; +use crate::dialogs::{Kind, apply_commands}; use crate::scenario::Harness; use crate::selection::{AXES, CURVES, POINTS, SelInput}; use crate::sketch_modify_ui::{Mod, commands}; @@ -395,3 +397,125 @@ fn the_x_distance_gets_a_drag_arrow_along_the_sketch_x_axis() { 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:?}"); } + +/// A sketch holding one circle, with the window for `cmd` open on it. +fn open(cmd: &str) -> (crate::scenario::Harness, String) { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center": [0, 0], "radius": 5})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + let id = h.app.session.model.state().sketch(sid).unwrap().sketch.curves[0].id.clone(); + h.step(&json!({"start": cmd})).unwrap(); + (h, id) +} + +fn rect(h: &mut crate::scenario::Harness, count: &str, spacing: &str, count2: &str, spacing2: &str) { + let d = h.app.dialog.as_mut().expect("the window opened"); + if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2 }) = &mut d.kind { + *c1 = count.into(); + *s1 = spacing.into(); + *c2 = count2.into(); + *s2 = spacing2.into(); + } else { + panic!("not the rectangular pattern window: {:?}", d.kind); + } +} + +#[test] +fn rectangular_pattern_window_opens_instead_of_a_click_tool() { + let (h, _) = open("sketch.pattern.rectangular"); + assert!(h.app.tool.is_none(), "the click tool still takes the command"); + let d = h.app.dialog.as_ref().expect("the window opened"); + assert!(matches!(d.kind, Kind::SketchModify(Mod::PatternRect { .. })), "{:?}", d.kind); + assert_eq!(d.inputs.iter().map(|i| i.label).collect::>(), vec!["Entities", "Direction"]); + // Nothing is committed by opening the window. + let sid = h.app.session.active_sketch.unwrap(); + assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(), 1); +} + +#[test] +fn typed_counts_and_spacings_reach_the_command_with_the_second_direction() { + let (mut h, id) = open("sketch.pattern.rectangular"); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }]; + rect(&mut h, "5", "20", "2", "15 mm"); + let d = h.app.dialog.clone().unwrap(); + let cmds = apply_commands(&h.app, &d).unwrap(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].0, "sketch.pattern.rectangular"); + // The click tool could only ever send a count of 3 and no second count at all. + assert_eq!(cmds[0].1["count"], json!(5)); + assert_eq!(cmds[0].1["spacing"], json!(20.0)); + assert_eq!(cmds[0].1["count2"], json!(2)); + assert_eq!(cmds[0].1["spacing2"], json!(15.0)); + assert!(cmds[0].1.get("dir").is_none(), "an unpicked direction leaves the command's own default"); +} + +#[test] +fn a_picked_axis_becomes_an_in_plane_direction() { + let (mut h, id) = open("sketch.pattern.rectangular"); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }]; + h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Axis { name: "Y".into() }]; + rect(&mut h, "3", "10", "1", "10"); + let d = h.app.dialog.clone().unwrap(); + let cmds = apply_commands(&h.app, &d).unwrap(); + assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]), "sketch y on the XY plane"); +} + +#[test] +fn an_axis_perpendicular_to_the_sketch_is_reported_not_silently_replaced() { + let (mut h, id) = open("sketch.pattern.rectangular"); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }]; + h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Axis { name: "Z".into() }]; + rect(&mut h, "3", "10", "1", "10"); + let d = h.app.dialog.clone().unwrap(); + let e = apply_commands(&h.app, &d).unwrap_err(); + assert!(e.contains("sketch plane"), "{e}"); +} + +/// Upstream #218 is the same shape: `solid.pattern.circular` took expressions in its axis origin +/// and stored `[0, 0, 0]`. The sketch patterns read `count` and `angle` with `num`, which takes a +/// JSON number only, so the window evaluates every field itself. +#[test] +fn a_count_expression_is_evaluated_rather_than_sent_as_a_string() { + let (mut h, id) = open("sketch.pattern.rectangular"); + h.app.run("parameters.add", json!({"name": "n", "expression": "4", "unit": ""})).unwrap(); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }]; + rect(&mut h, "n", "10", "1", "10"); + let d = h.app.dialog.clone().unwrap(); + let cmds = apply_commands(&h.app, &d).unwrap(); + assert_eq!(cmds[0].1["count"], json!(4)); +} + +#[test] +fn bad_counts_and_an_empty_selection_are_refused_before_the_command_runs() { + let (mut h, id) = open("sketch.pattern.rectangular"); + rect(&mut h, "5", "20", "1", "20"); + let d = h.app.dialog.clone().unwrap(); + assert!(apply_commands(&h.app, &d).unwrap_err().contains("Select")); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }]; + for bad in ["0", "2.5", "501", "wat"] { + rect(&mut h, bad, "20", "1", "20"); + let d = h.app.dialog.clone().unwrap(); + assert!(apply_commands(&h.app, &d).is_err(), "count {bad} was accepted"); + } + rect(&mut h, "5", "0", "1", "20"); + let d = h.app.dialog.clone().unwrap(); + assert!(apply_commands(&h.app, &d).unwrap_err().contains("Spacing")); +} + +/// The spacing is a geometric value, so it gets the viewport's value box and a drag arrow +/// through the existing `canvas::anchor` machinery: along the sketch's own x by default, along +/// the picked direction once one is picked. +#[test] +fn the_spacing_gets_a_canvas_arrow_along_the_pattern_direction() { + let (mut h, id) = open("sketch.pattern.rectangular"); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }]; + let d = h.app.dialog.clone().unwrap(); + let (_, dir) = crate::canvas::anchor(&h.app, &d).expect("an anchor on the picked curve"); + assert_eq!(dir, Some(solidcraft_engine::geom::Vec3::X)); + h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Axis { name: "Y".into() }]; + let d = h.app.dialog.clone().unwrap(); + let (_, dir) = crate::canvas::anchor(&h.app, &d).expect("an anchor on the picked curve"); + assert_eq!(dir, Some(solidcraft_engine::geom::Vec3::Y)); +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 337f8857..6af5ad08 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -361,7 +361,7 @@ fn mode(id: &str) -> Option { "sketch.circle.three_tangent" => Mode::Entities(3), "sketch.constrainer" | "sketch.constraint.polygon" => Mode::Entities(0), "sketch.centerline" => Mode::Entities(1), - "sketch.pattern.circular" | "sketch.pattern.rectangular" => Mode::EntitiesThen, + "sketch.pattern.circular" => Mode::EntitiesThen, "sketch.project_to_surface" | "sketch.intersection_curve" | "sketch.spun_profile" => Mode::Steps, _ => return None, }) @@ -420,7 +420,6 @@ pub fn hint(id: &str) -> Option { "sketch.constraint.polygon" => "Polygon: click the lines of a closed chain; right-click to apply", "sketch.centerline" => "Centerline: click lines", "sketch.pattern.circular" => "Circular pattern: click entities, right-click, then click the centre", - "sketch.pattern.rectangular" => "Rectangular pattern: click entities, right-click, then click two points (direction and spacing)", "sketch.project_to_surface" => "Project to surface: click curves of other sketches, right-click, then click the face", "sketch.intersection_curve" => "Intersection curve: click two faces", "sketch.spun_profile" => "Spun profile: click the body, then the axis line", @@ -721,26 +720,14 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { // Stage 2 (after a right-click) is marked by a "|" pick. if tool.picks.last().is_some_and(|x| x == "|") { let ents: Vec = tool.picks.iter().filter(|x| *x != "|").cloned().collect(); - match cmd { - "sketch.pattern.circular" => { - if let Some(p) = at { - let _ = app.run(cmd, json!({"entities": ents, "center": arg(&p), "count": 6})); - tool.picks.clear(); - } - } - _ => { - if let Some(p) = at { - tool.pts.push(p); - if tool.pts.len() >= 2 { - let d = tool.pts[1].0 - tool.pts[0].0; - if d.len() > 1e-9 { - let _ = app.run(cmd, json!({"entities": ents, "dir": xy(d), "count": 3, "spacing": d.len()})); - } - tool.picks.clear(); - tool.pts.clear(); - } - } - } + // `sketch.pattern.rectangular` left this flow for a tool window: its count was + // hard-coded to 3 here and the command's second count and spacing could not be + // reached at all. The circular pattern is the last command still on it. + if cmd == "sketch.pattern.circular" + && let Some(p) = at + { + let _ = app.run(cmd, json!({"entities": ents, "center": arg(&p), "count": 6})); + tool.picks.clear(); } return; } diff --git a/crates/ui-egui/tests/scenarios/sketch_pattern_rect_window.json b/crates/ui-egui/tests/scenarios/sketch_pattern_rect_window.json new file mode 100644 index 00000000..52952f27 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_pattern_rect_window.json @@ -0,0 +1,89 @@ +[ + {"note": "sketch.pattern.rectangular gets a tool window carrying Entities, Direction, Quantity, Spacing, Quantity 2 and Spacing 2. Its click tool sent a hard-coded count of 3 and could not reach the command's second count and spacing at all."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"expect": {"sketching": true}}, + {"start": "sketch.circle.center"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [5, 0]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 1}, "errors": 0}}, + + {"note": "Starting the command opens the window and commits nothing. Picking the circle fills Entities and still commits nothing."}, + {"start": "sketch.pattern.rectangular"}, + {"expect": {"dialog": "PatternRect", "sketch_curves": {"own": 1}, "errors": 0}}, + {"click": {"sketch": [5, 0]}}, + {"expect": {"dialog": "PatternRect", "sketch_curves": {"own": 1}, "errors": 0}}, + + {"note": "A typed count of 5 reaches the command unchanged, where the click tool always sent 3. Five instances 20 mm apart along sketch x put the last centre at x = 80."}, + {"click": {"handle": "dialog:Quantity"}}, + {"key": "A", "cmd": true}, + {"text": "5"}, + {"click": {"handle": "dialog:Spacing"}}, + {"key": "A", "cmd": true}, + {"text": "20"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "center_at", 0], "value": 80}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "center_at", 1], "value": 0}}}, + {"shot": "sketch_pattern_rect_count"}, + + {"note": "The second count and spacing are reachable: 3 by 2 from one circle makes six instances, the second row 15 mm along the perpendicular direction the command derives."}, + {"start": "sketch.circle.center"}, + {"click": {"sketch": [0, 60]}}, + {"click": {"sketch": [5, 60]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 6}, "errors": 0}}, + {"start": "sketch.pattern.rectangular"}, + {"click": {"sketch": [5, 60]}}, + {"click": {"handle": "dialog:Quantity"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"click": {"handle": "dialog:Spacing"}}, + {"key": "A", "cmd": true}, + {"text": "20"}, + {"click": {"handle": "dialog:Quantity 2"}}, + {"key": "A", "cmd": true}, + {"text": "2"}, + {"click": {"handle": "dialog:Spacing 2"}}, + {"key": "A", "cmd": true}, + {"text": "15"}, + {"click": {"handle": "dialog:OK"}}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 11}}}, + {"note": "The first copy is the second row of the first column: 15 mm along the perpendicular direction, nothing along the first."}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "center_at", 0], "value": 0}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "center_at", 1], "value": 75}}}, + {"note": "The last copy is the far corner of the 3 by 2 grid: 40 mm along the first direction, 15 mm along the second."}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 10, "center_at", 0], "value": 40}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 10, "center_at", 1], "value": 75}}}, + {"shot": "sketch_pattern_rect_grid"}, + + {"note": "A picked Direction reaches the command's dir: along the Y origin axis the instances run up the sketch, not across it. The grid above has grown the sketch well past the frame the first circle was drawn in, so refit the view before clicking a point 100 mm out."}, + {"call": "ui.view", "params": {"view": "fit"}}, + {"start": "sketch.circle.center"}, + {"click": {"sketch": [100, 0]}}, + {"click": {"sketch": [105, 0]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 12}, "errors": 0}}, + {"start": "sketch.pattern.rectangular"}, + {"click": {"sketch": [105, 0]}}, + {"click": {"handle": "dialog:Direction"}}, + {"click": {"axis": "Y"}}, + {"click": {"handle": "dialog:Quantity"}}, + {"key": "A", "cmd": true}, + {"text": "4"}, + {"click": {"handle": "dialog:Spacing"}}, + {"key": "A", "cmd": true}, + {"text": "10"}, + {"click": {"handle": "dialog:OK"}}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 15}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 14, "center_at", 0], "value": 100}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 14, "center_at", 1], "value": 30}}}, + + {"note": "Escape closes the window and leaves the sketch and its undo history exactly as they were."}, + {"start": "sketch.pattern.rectangular"}, + {"click": {"sketch": [105, 0]}}, + {"expect": {"dialog": "PatternRect"}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 15}}} +]