diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index a9c1e3b6..5315a58f 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -311,6 +311,7 @@ "ui.to_object": "Bis Objekt", "ui.top": "Oben", "ui.torus": "Torus", + "ui.total_angle": "Gesamtwinkel", "ui.trim": "Kürzen", "ui.two_sides": "Beidseitig", "ui.ui_scale": "UI-Skalierung", diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index 5cdd6914..9ff7c65a 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -599,6 +599,7 @@ "ui.to_object": "To Object", "ui.top": "Top", "ui.torus": "Torus", + "ui.total_angle": "Total angle", "ui.trackpad_pan_tip": "Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms", "ui.trim": "Trim", "ui.two_finger_scroll_pans": "Two-finger scroll pans", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index e31737b5..d03840bb 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -599,6 +599,7 @@ "ui.to_object": "オブジェクトまで", "ui.top": "上面", "ui.torus": "トーラス", + "ui.total_angle": "合計角度", "ui.trackpad_pan_tip": "トラックパッドのジェスチャーでパンします(オフの場合はマウスホイールと同様にズームします)。マウスホイールはズーム、サイドホイールは横方向のパン、ピンチ操作は常にズームになります。", "ui.trim": "トリム", "ui.two_finger_scroll_pans": "2本指スクロールでパン", diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index 832c4544..bd5d2a77 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -311,6 +311,7 @@ "ui.to_object": "객체까지", "ui.top": "상면", "ui.torus": "토러스", + "ui.total_angle": "전진 각도", "ui.trim": "트림", "ui.two_sides": "양방향", "ui.ui_scale": "UI 배율", diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 9db4eb96..b1c35c90 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -78,6 +78,9 @@ pub enum Mod { /// `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.pattern.circular`: how many instances around the picked centre, and the total + /// angle they span. + PatternCirc { count: String, angle: 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 @@ -95,6 +98,7 @@ impl Mod { Mod::Mirror => "MIRROR", Mod::Move { .. } => "MOVE/COPY", Mod::PatternRect { .. } => "RECTANGULAR PATTERN", + Mod::PatternCirc { .. } => "CIRCULAR PATTERN", Mod::Corner { chamfer: false, .. } => "FILLET", Mod::Corner { .. } => "CHAMFER", } @@ -108,6 +112,7 @@ impl Mod { Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)), Mod::Mirror => None, Mod::PatternRect { spacing, .. } => Some(("Spacing", ValueKind::Length, spacing)), + Mod::PatternCirc { angle, .. } => Some(("Angle", ValueKind::Angle, angle)), Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)), Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)), } @@ -124,6 +129,8 @@ fn inputs(k: &Mod) -> Vec { // 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)], + // The centre is required: `sketch.pattern.circular` has no default for it. + Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, 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)], @@ -147,6 +154,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "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.pattern.circular" => Mod::PatternCirc { count: "6".into(), angle: "360 deg".into() }, "sketch.fillet" => corner(false, 0), "sketch.chamfer.equal_distance" => corner(true, 0), "sketch.chamfer.two_distance" => corner(true, 1), @@ -211,6 +219,13 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { ui.end_row(); } } + Mod::PatternCirc { count, angle } => { + for (label, value) in [("Quantity", count), ("Total angle", angle)] { + 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"); @@ -267,6 +282,9 @@ pub fn arrow(app: &SolveApp, d: &Dialog) -> Option { }; Some(plane.dir_to_world(dir)) } + // A total angle is a rotation. The viewport draws a straight drag arrow and a rotation + // handle only for Revolve, through machinery tied to its own extents, so the circular + // pattern takes the value box alone until that manipulator is made general. _ => None, } } @@ -441,6 +459,23 @@ fn corner_ref(app: &SolveApp, point: &str, flip: bool) -> Result Ok(json!({"a": id(b)?, "b": id(a)?})) } +/// A typed total angle as the command wants it, in degrees. A bare number means degrees, as a +/// typed angle does everywhere else. `sketch.pattern.circular` takes the full turn as 360 and +/// refuses anything outside that range. +fn total_angle(app: &SolveApp, label: &str, text: &str) -> Result { + let v = app.session.doc.eval(text.trim(), ValueKind::Angle).map_err(|e| format!("{label}: {e}"))?.to_degrees(); + if !v.is_finite() || v.abs() <= 1e-9 || v.abs() > 360.0 { + return Err(format!("{label} must be more than 0 and at most 360 degrees")); + } + Ok(v) +} + +/// The bare sketch point id of a single point pick, as a command's `center` or `base` reads it. +/// `point_id` above takes one `Sel`; this one takes the first pick of an input. +fn picked_point(inputs: &[SelInput], i: usize) -> Option { + items(inputs, i).first().and_then(point_id).map(str::to_string) +} + /// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for /// 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. @@ -537,6 +572,19 @@ 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 center = picked_point(inputs, 1).ok_or("Select the centre the pattern turns around first")?; + let n = whole(app, "Quantity", count)?; + if n < 2 { + return Err("Quantity must be at least 2".into()); + } + let a = total_angle(app, "Total angle", angle)?; + ("sketch.pattern.circular", json!({"entities": entities, "center": center, "count": n, "angle": a})) + } 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 c0ce3744..071021bc 100644 --- a/crates/ui-egui/src/sketch_modify_ui_tests.rs +++ b/crates/ui-egui/src/sketch_modify_ui_tests.rs @@ -519,3 +519,96 @@ fn the_spacing_gets_a_canvas_arrow_along_the_pattern_direction() { let (_, dir) = crate::canvas::anchor(&h.app, &d).expect("an anchor on the picked curve"); assert_eq!(dir, Some(solidcraft_engine::geom::Vec3::Y)); } + +/// A sketch holding one circle 30 mm out and a lone point at the origin, with the circular +/// pattern window open on it and both inputs filled. The point is the centre the pattern turns +/// around; the command has no default for it. Returns the centre's bare point id. +fn open_circ() -> (crate::scenario::Harness, String) { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.point", json!({"point": [0, 0]})).unwrap(); + h.app.run("sketch.circle.center", json!({"center": [30, 0], "radius": 10})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + let (curve, centre) = { + let st = h.app.session.model.state(); + let sk = &st.sketch(sid).unwrap().sketch; + let curve = sk.curves[0].id.clone(); + let centre = sk.points.iter().find(|p| p.pos.len() < 1e-9).unwrap().id.clone(); + (curve, centre) + }; + h.step(&json!({"start": "sketch.pattern.circular"})).unwrap(); + let d = h.app.dialog.as_mut().unwrap(); + d.inputs[0].items = vec![Sel::SketchCurve { id: curve }]; + d.inputs[1].items = vec![Sel::SketchPoint { id: format!("{sid}:{centre}") }]; + (h, centre) +} + +fn circ(h: &mut crate::scenario::Harness, count: &str, angle: &str) { + let d = h.app.dialog.as_mut().expect("the window opened"); + if let Kind::SketchModify(Mod::PatternCirc { count: c, angle: a }) = &mut d.kind { + *c = count.into(); + *a = angle.into(); + } else { + panic!("not the circular pattern window: {:?}", d.kind); + } +} + +#[test] +fn circular_pattern_window_opens_instead_of_a_click_tool() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.settle(); + h.step(&json!({"start": "sketch.pattern.circular"})).unwrap(); + 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::PatternCirc { .. })), "{:?}", d.kind); + assert_eq!(d.inputs.iter().map(|i| i.label).collect::>(), vec!["Entities", "Center"]); +} + +#[test] +fn the_typed_count_and_total_angle_reach_the_command() { + let (mut h, centre) = open_circ(); + circ(&mut h, "4", "180"); + 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.circular"); + // The click flow could only ever send a count of 6, and never any angle. + assert_eq!(cmds[0].1["count"], json!(4)); + assert_eq!(cmds[0].1["angle"], json!(180.0)); + assert_eq!(cmds[0].1["center"], json!(centre), "the bare point id the command resolves"); + circ(&mut h, "4", "90 deg"); + let d = h.app.dialog.clone().unwrap(); + assert_eq!(apply_commands(&h.app, &d).unwrap()[0].1["angle"], json!(90.0)); +} + +/// The same lesson as upstream #218: `num` reads a JSON number only, and `sketch.pattern.circular` +/// falls back to a full turn when `angle` is anything else. An expression has to be evaluated +/// here or it disappears in silence. +#[test] +fn an_angle_expression_is_evaluated_rather_than_sent_as_a_string() { + let (mut h, _) = open_circ(); + h.app.run("parameters.add", json!({"name": "sweep", "expression": "120", "unit": "deg"})).unwrap(); + circ(&mut h, "3", "sweep"); + let d = h.app.dialog.clone().unwrap(); + let angle = apply_commands(&h.app, &d).unwrap()[0].1["angle"].as_f64().expect("a number, not the text `sweep`"); + assert!((angle - 120.0).abs() < 1e-9, "{angle}"); +} + +#[test] +fn a_missing_centre_a_short_count_and_a_bad_angle_are_all_refused() { + let (mut h, _) = open_circ(); + for bad in ["0", "400 deg", "wat"] { + circ(&mut h, "4", bad); + let d = h.app.dialog.clone().unwrap(); + assert!(apply_commands(&h.app, &d).unwrap_err().contains("Total angle"), "angle {bad} was accepted"); + } + circ(&mut h, "1", "360 deg"); + let d = h.app.dialog.clone().unwrap(); + assert!(apply_commands(&h.app, &d).unwrap_err().contains("Quantity")); + circ(&mut h, "4", "360 deg"); + h.app.dialog.as_mut().unwrap().inputs[1].items.clear(); + let d = h.app.dialog.clone().unwrap(); + assert!(apply_commands(&h.app, &d).unwrap_err().contains("centre")); +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 4338e5ae..bc843639 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -335,8 +335,6 @@ enum Mode { Points(usize), /// Pick sketch entities; `n` run the command (0: until right-click). Entities(usize), - /// Pick entities until right-click, then one more pick or point (mirror line, centre). - EntitiesThen, /// Model faces / sketch curves in steps (see the command). Steps, } @@ -359,7 +357,6 @@ 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" => Mode::EntitiesThen, "sketch.project_to_surface" | "sketch.intersection_curve" | "sketch.spun_profile" => Mode::Steps, _ => return None, }) @@ -417,7 +414,6 @@ pub fn hint(id: &str) -> Option { "sketch.constrainer" => "Constrain: click one or two entities; right-click to apply", "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.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", @@ -714,27 +710,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { tool.pts.clear(); } } - Mode::EntitiesThen => { - // 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(); - // `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; - } - if let Some((id, _)) = sketch_entity(app, &hits) - && !tool.picks.contains(&id) - { - tool.picks.push(id); - } - } Mode::Steps => match cmd { "sketch.intersection_curve" => { if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("face").is_some()) { @@ -865,7 +840,7 @@ pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool { tool.pts.clear(); true } - Mode::EntitiesThen | Mode::Steps if !tool.picks.is_empty() && tool.picks.last().is_some_and(|x| x != "|") => { + Mode::Steps if !tool.picks.is_empty() && tool.picks.last().is_some_and(|x| x != "|") => { tool.picks.push("|".into()); true } diff --git a/crates/ui-egui/tests/scenarios/sketch_pattern_circ_window.json b/crates/ui-egui/tests/scenarios/sketch_pattern_circ_window.json new file mode 100644 index 00000000..50071ef9 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_pattern_circ_window.json @@ -0,0 +1,60 @@ +[ + {"note": "sketch.pattern.circular gets a tool window carrying Entities, Center, Quantity and Total angle. Its click flow sent a count hard-coded to 6 and never passed an angle at all."}, + {"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}}, + {"call": "engine.execute", "params": {"command": "sketch.point", "params": {"point": [0, 0]}}}, + {"call": "engine.execute", "params": {"command": "sketch.circle.center", "params": {"center": [30, 0], "radius": 10}}}, + {"call": "ui.view", "params": {"view": "top"}}, + {"expect": {"sketching": true, "sketch_curves": {"own": 1}, "errors": 0}}, + + {"note": "Starting the command opens the window and commits nothing. Picking the circle and the centre point still commits nothing."}, + {"start": "sketch.pattern.circular"}, + {"expect": {"dialog": "PatternCirc", "sketch_curves": {"own": 1}, "errors": 0}}, + {"click": {"sketch": [40, 0]}}, + {"click": {"handle": "dialog:Center"}}, + {"click": {"sketch": [0, 0]}}, + {"expect": {"dialog": "PatternCirc", "sketch_curves": {"own": 1}, "errors": 0}}, + + {"note": "A count of 4 over a total angle of 180 degrees: the old flow could only send 6 over the full turn. Four instances 60 degrees apart put the last one straight across the centre."}, + {"click": {"handle": "dialog:Quantity"}}, + {"key": "A", "cmd": true}, + {"text": "4"}, + {"click": {"handle": "dialog:Total angle"}}, + {"key": "A", "cmd": true}, + {"text": "180"}, + {"click": {"handle": "dialog:OK"}}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 4}}}, + {"note": "60 degrees around: 30 mm at 60 degrees is (15, 25.980762)."}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "center_at", 0], "value": 15}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "center_at", 1], "value": 25.980762113533157}}}, + {"note": "180 degrees around: straight across the centre. The angle was applied, not ignored."}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 3, "center_at", 0], "value": -30}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 3, "center_at", 1], "value": 0}}}, + {"shot": "sketch_pattern_circ_half_turn"}, + + {"note": "A full turn with a count that is not 6 either: four instances 90 degrees apart around a second centre. The half turn above reaches x = -30, and the second circle reaches y = 90, so refit before picking anything up there."}, + {"call": "engine.execute", "params": {"command": "sketch.point", "params": {"point": [0, 60]}}}, + {"call": "engine.execute", "params": {"command": "sketch.circle.center", "params": {"center": [0, 80], "radius": 10}}}, + {"call": "ui.view", "params": {"view": "fit"}}, + {"expect": {"sketch_curves": {"own": 5}, "errors": 0}}, + {"start": "sketch.pattern.circular"}, + {"click": {"sketch": [10, 80]}}, + {"click": {"handle": "dialog:Center"}}, + {"click": {"sketch": [0, 60]}}, + {"click": {"handle": "dialog:Quantity"}}, + {"key": "A", "cmd": true}, + {"text": "4"}, + {"click": {"handle": "dialog:OK"}}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "center_at", 0], "value": -20}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "center_at", 1], "value": 60}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 7, "center_at", 0], "value": 20}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 7, "center_at", 1], "value": 60}}}, + {"shot": "sketch_pattern_circ_full_turn"}, + + {"note": "Escape closes the window and leaves the sketch and its undo history exactly as they were."}, + {"start": "sketch.pattern.circular"}, + {"click": {"sketch": [10, 80]}}, + {"expect": {"dialog": "PatternCirc"}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}} +]