A tool window for the circular sketch pattern (#801)

Circular sketch pattern gets a tool window with entities, center, quantity and total angle, replacing the click flow.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 16:34:00 -04:00
1 parent e99b3bd4e8
commit 152418b624
8 files changed
+206 -26

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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本指スクロールでパン",
+1
View File
@@ -311,6 +311,7 @@
"ui.to_object": "객체까지",
"ui.top": "상면",
"ui.torus": "토러스",
"ui.total_angle": "전진 각도",
"ui.trim": "트림",
"ui.two_sides": "양방향",
"ui.ui_scale": "UI 배율",
+48
View File
@@ -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<SelInput> {
// 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<SelInput>)> {
"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<Vec3> {
};
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<Value, String>
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<f64, String> {
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<String> {
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<String
}
("sketch.pattern.rectangular", p)
}
Mod::PatternCirc { count, angle } => {
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() {
@@ -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<_>>(), 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"));
}
+1 -26
View File
@@ -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<Mode> {
"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<String> {
"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<String> = 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
}
@@ -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}}}
]