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) <noreply@anthropic.com>
This commit is contained in:
William DunnandClaude Opus 5 authored and GitHub committed 2026-10-13 14:54:48 -04:00
1 parent e540832626
commit 3ea8562c5e
9 files changed
+353 -30

No files matched your search

+4 -3
View File
@@ -101,9 +101,10 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
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),
})
}
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "ボディを分割",
+4
View File
@@ -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": "바디 분할",
+109 -3
View File
@@ -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<SelInput> {
// 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<SelInput>)> {
"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<Vec2> {
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<Vec3> {
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<f64, String>
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<u64, String> {
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<f64, String> {
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
/// "<sketch id>:<point id>"; a point selected without the window carries the bare id.
fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec<String> {
@@ -393,6 +472,33 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
}
("sketch.move", p)
}
Mod::PatternRect { count, spacing, count2, spacing2 } => {
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() {
+126 -2
View File
@@ -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<_>>(), 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));
}
+9 -22
View File
@@ -361,7 +361,7 @@ 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" | "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<String> {
"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<String> = 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;
}