ui: the thread dialog offers every option of solid.thread

Form (ISO metric, trapezoidal, Acme, ISO and ASME buttress, square), size and
pitch from the form's list, hand, starts, extent (full, length or up to, with
the end to start from), ease in and out (standard, short or a length), the
chamfered tip and the buttress steep flank. Only the fields that apply are
shown; a form change keeps the options and a manual size's diameter where
the new form lists it. Defaults stay out of the command, so a plain thread
means what it did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-26 08:42:58 -04:00
parent 350d2884ac
commit eb9c424b45
6 changed files with 645 additions and 79 deletions
+19 -24
View File
@@ -246,6 +246,7 @@ crate::dialog_descriptor::dialog_descriptors! {
suggested: Option<String>,
/// The picks used for the suggestion, so an unchanged dialog does no geometry work.
of: Vec<Sel>,
options: crate::dialogs_thread::Options,
},
/// A rib (one wall along open curves) or a web (a wall per curve).
Rib {
@@ -865,14 +866,8 @@ impl Dialog {
vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES | AXES, false)],
),
"solid.thread" => Dialog::new(
Kind::Thread {
designation: String::new(),
length: String::new(),
handedness: ThreadHandedness::Right,
suggested: None,
of: Vec::new(),
},
vec![SelInput::new("Cylindrical face", FACES, false)],
Kind::Thread { designation: String::new(), length: String::new(), suggested: None, of: Vec::new(), options: Default::default() },
crate::dialogs_thread::inputs(),
),
"material.assign" => Dialog::new(Kind::Material { index: 1, editor: Default::default() }, vec![SelInput::new("Bodies", BODIES, true)]),
"inspect.section" => Dialog::new(
@@ -1108,8 +1103,8 @@ impl Dialog {
/// Resolve a newly selected face without replacing an authored size or changing the
/// document. Clearing the face also clears a suggestion that no longer applies.
fn sync_thread(&mut self, s: &Session) {
let Kind::Thread { designation, suggested, of, .. } = &mut self.kind else { return };
if !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) {
let Kind::Thread { designation, suggested, of, options, .. } = &mut self.kind else { return };
if !options.suggests() || !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) {
return;
}
let picks = self.inputs.first().map(|i| i.items.as_slice()).unwrap_or_default();
@@ -3240,8 +3235,14 @@ pub(crate) fn preset_to_object(d: &mut Dialog, on: bool) {
}
fn set_object(inputs: &mut Vec<SelInput>, active: &mut usize, label: &'static str, on: bool) {
set_object_accepting(inputs, active, label, FACES | selection::VERTICES, on);
}
/// Add or remove the pick input `label` accepting `accept`; the active input moves to it when it
/// is added.
pub(crate) fn set_object_accepting(inputs: &mut Vec<SelInput>, active: &mut usize, label: &'static str, accept: selection::Accept, on: bool) {
if on && !object_on(inputs, label) {
inputs.push(SelInput::new(label, FACES | selection::VERTICES, false));
inputs.push(SelInput::new(label, accept, false));
*active = inputs.len() - 1;
} else if !on && object_on(inputs, label) {
inputs.retain(|i| i.label != label);
@@ -4284,16 +4285,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
need(0, "faces")?;
("solid.offset_face", json!({"faces": face_points(0)}))
}
Kind::Thread { designation, length, suggested, handedness, .. } => {
Kind::Thread { designation, length, suggested, options, .. } => {
need(0, "a cylindrical face")?;
let mut p = json!({"face": face_points(0).into_iter().next().unwrap_or(Value::Null)});
if !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) {
p["designation"] = json!(designation.trim());
}
put(&mut p, &[("length", length)]);
if !handedness.is_right() {
p["handedness"] = json!(handedness);
}
crate::dialogs_thread::put_params(&mut p, length, options, &d.inputs);
("solid.thread", p)
}
Kind::Material { index, editor } => {
@@ -5824,18 +5822,15 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Thread { face, designation, length, modeled, handedness } => {
FeatureKind::Thread { face, designation, length, modeled, .. } => {
let mut d = start("solid.thread")?;
if *modeled {
d.extra.insert("modeled".into(), json!(true));
}
d.kind = Kind::Thread {
designation: designation.clone().unwrap_or_default(),
length: length.as_deref().map(str::to_string).unwrap_or_default(),
handedness: *handedness,
suggested: None,
of: Vec::new(),
};
let designation = designation.clone().unwrap_or_default();
let length = length.as_deref().map(str::to_string).unwrap_or_default();
let options = crate::dialogs_thread::Options::for_saved(&designation, &length);
d.kind = Kind::Thread { designation, length, suggested: None, of: Vec::new(), options };
if let Some(inp) = d.inputs.first_mut() {
inp.items = face_sel(s, face.0).into_iter().collect();
}
+3 -3
View File
@@ -177,7 +177,7 @@ fn check_form(command: &str, labels: &[&str]) {
"inspect.section" => &["Flip", "Hatch"],
"solid.move" => &["Create Copy"],
"solid.combine" => &["Keep Tools", "New Component"],
"solid.thread" => &["Modeled", "Full Length", "Automatic size"],
"solid.thread" => &["Modeled", "Automatic size"],
"solid.boundary_fill" => &["Remove Tools"],
"surface.offset" => &["Tangent chain"],
"surface.trim" => &["Extend to meet", "Stitch the kept pieces"],
@@ -234,7 +234,7 @@ fn control_role(command: &str, label: &str) -> Option<egui::accesskit::Role> {
| "Bend Line Position" | "From Snap" | "To Snap" | "Joint" | "Joint 1" | "Joint 2" | "Gear 1" | "Gear 2" | "Pinion" | "Rack" | "Screw"
| "Nut" | "Cam" | "Follower" | "Anchor" => Role::ComboBox,
"Radius" if command == "joint.rack_pinion" => Role::Label,
"Pitch" if command == "solid.thread" => Role::ComboBox,
"Pitch" | "Form" | "Hand" | "Starts" | "Extent" | "Ease In" | "Ease Out" | "Tip" if command == "solid.thread" => Role::ComboBox,
"Automatic size" | "Reverse" | "Other side" | "All Lines" | "Mirror" => Role::CheckBox,
"Animate" => Role::Button,
"Ratio" if command.starts_with("joint.") && command != "joint.motion_link" => return None,
@@ -362,7 +362,7 @@ form_case!(path_pattern, "solid.pattern.path", ["Type", "Object Type", "Quantity
form_case!(pipe, "solid.pipe", ["Diameter", "Wall", "Operation"]);
form_case!(stock, "solid.bounding_solid", ["Margin"]);
form_case!(frame_member, "frame.member", ["All Lines", "Anchor", "Angle", "Mirror", "Name"]);
form_case!(thread, "solid.thread", ["Profile", "Size", "Pitch", "Designation"]);
form_case!(thread, "solid.thread", ["Form", "Size", "Pitch", "Designation", "Hand", "Starts", "Extent", "Ease In", "Ease Out", "Tip"]);
form_case!(primitive_box, "solid.box", ["length", "width", "height", "Operation"]);
form_case!(cylinder, "solid.cylinder", ["diameter", "height", "Operation"]);
form_case!(sphere, "solid.sphere", ["diameter", "Operation"]);
+3 -11
View File
@@ -758,26 +758,18 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo
Kind::OffsetFaces { distance } => {
enter |= expr_row(ui, "Distance", distance);
}
Kind::Thread { designation, length, suggested, of, handedness } => {
let mut full = length.trim().is_empty();
let was_full = full;
Kind::Thread { designation, length, suggested, of, options } => {
let mut modeled = d.extra.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let before = modeled;
let (submitted, reset) = crate::dialogs_thread::rows(ui, designation, suggested, &mut modeled, &mut full);
crate::dialogs_thread::handedness_row(ui, handedness, "thread_handedness");
let (submitted, reset) = crate::dialogs_thread::rows(ui, designation, suggested, length, options, &mut modeled);
if modeled != before {
d.extra.insert("modeled".into(), json!(modeled));
}
if full != was_full {
*length = if full { String::new() } else { length_default(&app.session, "10") };
}
enter |= submitted;
if reset {
of.clear();
}
if !full {
enter |= expr_row(ui, "Length", length);
}
crate::dialogs_thread::sync_inputs(&mut d.inputs, &mut d.active, options);
}
Kind::Loft { operation, rail_fit } => {
choice_row(ui, "Operation", "lf_op", operation, &OP_LABELS);
+370 -37
View File
@@ -1,15 +1,20 @@
//! Tapped-hole catalogues and metric Thread choices share document sizing data.
//! Browsing choices does not edit the model or freeze an automatic Thread feature.
//! The thread dialog: form, size, hand, starts, extent, runouts, tip and the modelled option of
//! `solid.thread`. Choices only build the command's parameters: the command checks the geometry
//! and its error text is what the dialog shows. Browsing choices does not edit the model or
//! freeze an automatic feature.
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{
Document, TappedThreadFamily, ThreadHandedness, metric_thread_sizes, parse_metric_thread, parse_tapped_thread, tapped_thread_sizes,
};
use crate::dialogs::{combo, form_row};
use crate::dialogs::{combo, form_row, object_point, put, set_object_accepting};
use crate::i18n::tr;
use crate::ui_kit::text_row;
use crate::selection::{Accept, EDGES, FACES, PLANES, SelInput};
use crate::ui_kit::{choice_row, expr_row, hint_row, text_row};
/// Common pitch presets, not an exhaustive catalogue of standard diameter/pitch pairs.
/// Common metric pitch presets, not an exhaustive catalogue of standard diameter/pitch pairs.
/// The coarse and current pitches are included separately; custom pairs remain editable.
const PITCHES: &[f64] = &[0.2, 0.25, 0.35, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
@@ -156,19 +161,243 @@ pub(crate) fn handedness_row(ui: &mut egui::Ui, handedness: &mut ThreadHandednes
*handedness = if selected == 1 { ThreadHandedness::Left } else { ThreadHandedness::Right };
}
/// Returns (Enter pressed, automatic sizing requested).
pub(crate) fn rows(ui: &mut egui::Ui, designation: &mut String, suggested: &mut Option<String>, modeled: &mut bool, full: &mut bool) -> (bool, bool) {
form_row(ui, "Profile", |ui| {
ui.label(tr("thread.iso_metric"));
});
/// ISO 2904 (DIN 103-2) trapezoidal diameter/pitch combinations, the preferred (medium) pitch
/// first. ISO 3030 buttress threads use the same combinations from 10 mm up.
const TRAPEZOIDAL: &[(f64, &[f64])] = &[
(8.0, &[1.5]),
(9.0, &[2.0, 1.5]),
(10.0, &[2.0, 1.5]),
(11.0, &[3.0, 2.0]),
(12.0, &[3.0, 2.0]),
(14.0, &[3.0, 2.0]),
(16.0, &[4.0, 2.0]),
(18.0, &[4.0, 2.0]),
(20.0, &[4.0, 2.0]),
(22.0, &[5.0, 3.0, 8.0]),
(24.0, &[5.0, 3.0, 8.0]),
(26.0, &[5.0, 3.0, 8.0]),
(28.0, &[5.0, 3.0, 8.0]),
(30.0, &[6.0, 3.0, 10.0]),
(32.0, &[6.0, 3.0, 10.0]),
(36.0, &[6.0, 3.0, 10.0]),
(40.0, &[7.0, 3.0, 10.0]),
(44.0, &[7.0, 3.0, 12.0]),
(48.0, &[8.0, 3.0, 12.0]),
(50.0, &[8.0, 3.0, 12.0]),
(52.0, &[8.0, 3.0, 12.0]),
(60.0, &[9.0, 3.0, 14.0]),
(70.0, &[10.0, 4.0, 16.0]),
(80.0, &[10.0, 4.0, 16.0]),
(90.0, &[12.0, 4.0, 18.0]),
(100.0, &[12.0, 4.0, 20.0]),
];
/// A thread form: the command's `form` value, the dialog's label, and the designation prefix of
/// the forms whose sizes the dialog lists (the others take a typed designation, or the size
/// that fits the face).
pub(crate) struct Form {
pub value: &'static str,
pub label: &'static str,
prefix: Option<&'static str>,
buttress: bool,
}
pub(crate) const FORMS: &[Form] = &[
Form { value: "metric", label: "ISO metric", prefix: Some("M"), buttress: false },
Form { value: "trapezoidal", label: "Trapezoidal (ISO 2904)", prefix: Some("Tr"), buttress: false },
Form { value: "acme", label: "Acme (ASME B1.5)", prefix: None, buttress: false },
Form { value: "buttress", label: "Buttress (ISO 3030)", prefix: Some("S"), buttress: true },
Form { value: "buttress_asme", label: "Buttress (ASME B1.9)", prefix: None, buttress: true },
Form { value: "square", label: "Square", prefix: None, buttress: false },
];
const HANDS: &[(&str, &str)] = &[("Right", "right"), ("Left", "left"), ("Both", "both")];
const STARTS: &[&str] = &["1", "2", "3", "4", "5", "6", "7", "8"];
const FULL: usize = 0;
const LENGTH: usize = 1;
const UP_TO_EXTENT: usize = 2;
const EXTENTS: &[(&str, &str)] = &[("Full", "full"), ("Length", "length"), ("Up to", "up_to")];
/// Runout choices (ISO 3508); the last one is a typed length.
const EASES: &[(&str, &str)] = &[("None", ""), ("Standard (2.5 P)", "standard"), ("Short (1.25 P)", "short"), ("Length", "")];
const EASE_LENGTH: usize = 3;
const TIPS: &[(&str, &str)] = &[("None", "none"), ("Chamfer", "chamfer")];
const FLANKS: &[(&str, &str)] = &[("End", "end"), ("Start", "start")];
/// The pick of the end the thread is measured from (`from`).
const START_END: &str = "Start end (optional)";
/// The face or plane an Up To extent runs to (`up_to`).
const UP_TO: &str = "Up to";
/// What the Up To pick accepts.
const UP_TO_ACCEPT: Accept = FACES | PLANES;
/// The selection inputs of a new thread dialog.
pub(crate) fn inputs() -> Vec<SelInput> {
vec![SelInput::new("Cylindrical face", FACES, false)]
}
/// Add the picks the options use, and take away the others: the end to start from (optional,
/// so it does not take the next click), and the Up To place (which does).
pub(crate) fn sync_inputs(inputs: &mut Vec<SelInput>, active: &mut usize, o: &Options) {
let was = *active;
set_object_accepting(inputs, active, START_END, EDGES, o.starts_at_an_end());
*active = was.min(inputs.len().saturating_sub(1));
set_object_accepting(inputs, active, UP_TO, UP_TO_ACCEPT, o.extent == UP_TO_EXTENT);
}
/// A runout at one end: a choice from the runout list and the typed length.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Ease {
pub choice: usize,
pub length: String,
}
/// The thread options beyond the size and length (indices into this module's choice lists).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Options {
pub form: usize,
pub hand: usize,
/// The number of starts less one.
pub starts: usize,
pub extent: usize,
pub ease_in: Ease,
pub ease_out: Ease,
pub tip: usize,
pub chamfer_angle: String,
pub chamfer_depth: String,
pub steep_flank: usize,
}
impl Options {
/// The options of a saved thread: the form its designation names (M, Tr or S), and a
/// Length extent when it has a length.
pub fn for_saved(designation: &str, length: &str) -> Options {
Options { form: form_of(designation), extent: if length.trim().is_empty() { FULL } else { LENGTH }, ..Options::default() }
}
/// Does the thread start at one end of the face (a length or an Up To extent, a runout or a
/// tip)? Then the end to start from can be picked.
pub fn starts_at_an_end(&self) -> bool {
self.extent != FULL || self.ease_in.choice != 0 || self.ease_out.choice != 0 || self.tip != 0
}
/// Does the dialog suggest a size from the face? The face's suggestion is an ISO metric size.
pub fn suggests(&self) -> bool {
self.form == 0
}
}
/// The form a designation's prefix names: Tr is trapezoidal, S buttress, anything else metric.
fn form_of(designation: &str) -> usize {
let t = designation.trim();
let index = |value: &str| FORMS.iter().position(|f| f.value == value).unwrap_or(0);
if t.starts_with("Tr") {
index("trapezoidal")
} else if t.strip_prefix('S').is_some_and(|r| r.starts_with(|c: char| c.is_ascii_digit())) {
index("buttress")
} else {
0
}
}
/// The sizes a form lists: each diameter with its pitches, the standard pitch first.
fn sizes(form: &Form) -> Vec<(f64, Vec<f64>)> {
match form.value {
"metric" => metric_thread_sizes().iter().map(|(d, coarse)| (*d, vec![*coarse])).collect(),
"trapezoidal" => TRAPEZOIDAL.iter().map(|(d, p)| (*d, p.to_vec())).collect(),
"buttress" => TRAPEZOIDAL.iter().filter(|(d, _)| *d >= 10.0).map(|(d, p)| (*d, p.to_vec())).collect(),
_ => Vec::new(),
}
}
/// The diameter and pitch a designation of this form names. A multi-start designation
/// (`Tr40x14(P7)`) names the lead and, in brackets, the pitch.
fn parse(form: &Form, designation: &str) -> Option<(f64, f64)> {
let prefix = form.prefix?;
if prefix == "M" {
return parse_metric_thread(designation);
}
let rest = designation.trim().strip_prefix(prefix)?;
let (d, rest) = rest.split_once(['x', 'X', '×'])?;
let pitch = match rest.split_once('(') {
Some((_, p)) => p.trim().strip_suffix(')')?.trim().strip_prefix(['P', 'p'])?,
None => rest,
};
let (d, p) = (d.trim().parse::<f64>().ok()?, pitch.trim().parse::<f64>().ok()?);
(d > 0.0 && p > 0.0).then_some((d, p))
}
fn designation_of(form: &Form, d: f64, p: f64) -> String {
format!("{}{d}x{p}", form.prefix.unwrap_or_default())
}
/// The pitches the Pitch choice offers for diameter `d` (largest first), with the current one.
fn pitches(form: &Form, d: f64, current: f64) -> Vec<f64> {
let listed = sizes(form).into_iter().find(|(n, _)| *n == d).map(|(_, p)| p);
let mut pitches = Vec::new();
if form.value == "metric" {
let coarse = listed.as_ref().and_then(|p| p.first().copied());
pitches.extend(PITCHES.iter().copied().filter(|p| *p < d && *p <= coarse.unwrap_or(d)));
pitches.extend(coarse);
} else {
pitches.extend(listed.unwrap_or_default());
}
pitches.push(current);
pitches.sort_by(|a, b| b.total_cmp(a));
pitches.dedup();
pitches
}
/// A new form keeps a manual size's diameter when the form lists it (with the form's standard
/// pitch); any other size goes back to the size that fits the face. True when the size went
/// back to automatic.
fn change_form(designation: &mut String, suggested: &mut Option<String>, from: usize, to: usize) -> bool {
let automatic = designation.trim().is_empty() || suggested.as_deref() == Some(designation.as_str());
let kept = (!automatic)
.then(|| parse(&FORMS[from], designation))
.flatten()
.and_then(|(d, _)| sizes(&FORMS[to]).into_iter().find(|(n, _)| *n == d))
.and_then(|(d, p)| p.first().map(|p| designation_of(&FORMS[to], d, *p)));
*suggested = None;
match kept {
Some(kept) => {
*designation = kept;
false
}
None => {
designation.clear();
true
}
}
}
/// The thread's rows. Returns (Enter pressed, automatic sizing requested). The picks are the
/// caller's: [`sync_inputs`] adds or removes them by the options.
pub(crate) fn rows(
ui: &mut egui::Ui,
designation: &mut String,
suggested: &mut Option<String>,
length: &mut String,
o: &mut Options,
modeled: &mut bool,
) -> (bool, bool) {
let mut enter = false;
let mut reset = false;
let form_labels: Vec<&str> = FORMS.iter().map(|f| f.label).collect();
let mut form = o.form;
choice_row(ui, "Form", "thread_form", &mut form, &form_labels);
if form != o.form {
reset |= change_form(designation, suggested, o.form, form);
o.form = form;
}
let f = &FORMS[o.form];
let before = designation.trim().is_empty() || suggested.as_deref() == Some(designation.as_str());
let mut automatic = before;
crate::ui_kit::section(ui, "thread_options", "Options", true, |ui| {
crate::ui_kit::checkbox_grid(ui, &mut [("Modeled", modeled), ("Full Length", full)], 2);
crate::ui_kit::checkbox_grid(ui, &mut [("Modeled", modeled)], 2);
ui.add_enabled_ui(!designation.trim().is_empty(), |ui| crate::ui_kit::checkbox_grid(ui, &mut [("Automatic size", &mut automatic)], 2));
});
let reset = automatic && !before;
reset |= automatic && !before;
if automatic != before {
*suggested = None;
if automatic {
@@ -176,8 +405,72 @@ pub(crate) fn rows(ui: &mut egui::Ui, designation: &mut String, suggested: &mut
}
}
let current = parse_metric_thread(designation);
let mut diameters: Vec<f64> = metric_thread_sizes().iter().map(|(d, _)| *d).collect();
if f.prefix.is_some() {
size_rows(ui, f, designation, suggested);
}
let hint = match f.prefix {
Some(prefix) => format!("{}{}", tr("fits the face, e.g. "), designation_of(f, 20.0, if prefix == "M" { 2.5 } else { 4.0 })),
None => tr("fits the face").to_string(),
};
let (submitted, changed) = text_row(ui, "Designation", designation, &hint);
enter |= submitted;
if let Some(at) = crate::scenario::handle_at("dialog:Designation") {
crate::scenario::publish_handle("dialog:thread_size", at);
}
if changed {
*suggested = None;
}
let hands: Vec<&str> = HANDS.iter().map(|h| h.0).collect();
choice_row(ui, "Hand", "thread_hand", &mut o.hand, &hands);
choice_row(ui, "Starts", "thread_starts", &mut o.starts, STARTS);
if f.buttress {
let flanks: Vec<&str> = FLANKS.iter().map(|x| x.0).collect();
choice_row(ui, "Steep Flank", "thread_flank", &mut o.steep_flank, &flanks);
}
let extents: Vec<&str> = EXTENTS.iter().map(|x| x.0).collect();
let extent = o.extent;
choice_row(ui, "Extent", "thread_extent", &mut o.extent, &extents);
if o.extent == LENGTH {
if extent != LENGTH && length.trim().is_empty() {
*length = "10 mm".into();
}
enter |= expr_row(ui, "Length", length);
}
crate::ui_kit::section(ui, "thread_ends", "Ends", true, |ui| {
enter |= ease_rows(ui, "Ease In", "thread_ease_in", &mut o.ease_in);
enter |= ease_rows(ui, "Ease Out", "thread_ease_out", &mut o.ease_out);
let tips: Vec<&str> = TIPS.iter().map(|x| x.0).collect();
choice_row(ui, "Tip", "thread_tip", &mut o.tip, &tips);
if o.tip == 1 {
enter |= hint_row(ui, "Chamfer Angle", &mut o.chamfer_angle, tr("ISO 4753"));
enter |= hint_row(ui, "Chamfer Depth", &mut o.chamfer_depth, tr("ISO 4753"));
}
});
(enter, reset)
}
/// A runout choice, with its length when typed.
fn ease_rows(ui: &mut egui::Ui, label: &'static str, id: &str, e: &mut Ease) -> bool {
let labels: Vec<&str> = EASES.iter().map(|x| x.0).collect();
let before = e.choice;
choice_row(ui, label, id, &mut e.choice, &labels);
if e.choice != EASE_LENGTH {
return false;
}
if before != EASE_LENGTH && e.length.trim().is_empty() {
e.length = "1 mm".into();
}
expr_row(ui, if label == "Ease In" { "Ease In Length" } else { "Ease Out Length" }, &mut e.length)
}
/// The Size and Pitch choices of a form with a size list.
fn size_rows(ui: &mut egui::Ui, f: &Form, designation: &mut String, suggested: &mut Option<String>) {
let table = sizes(f);
let current = parse(f, designation);
let mut diameters: Vec<f64> = table.iter().map(|(d, _)| *d).collect();
if let Some((d, _)) = current
&& !diameters.contains(&d)
{
@@ -193,29 +486,21 @@ pub(crate) fn rows(ui: &mut egui::Ui, designation: &mut String, suggested: &mut
labels.push("Select size");
}
combo(ui, "thread_diameter", &labels, &mut selected);
// A new diameter starts with its coarse pitch. The current custom diameter, if any,
// A new diameter starts with its standard pitch. The current custom diameter, if any,
// is already selected and retains its explicit pitch.
if selected != before
&& let Some(d) = diameters.get(selected)
&& let Some((_, coarse)) = metric_thread_sizes().iter().find(|(n, _)| n == d)
&& let Some(p) = table.iter().find(|(n, _)| n == d).and_then(|(_, p)| p.first())
{
*designation = self::designation(*d, *coarse);
*designation = designation_of(f, *d, *p);
*suggested = None;
}
});
let current = parse_metric_thread(designation);
let current = parse(f, designation);
form_row(ui, "Pitch", |ui| {
ui.add_enabled_ui(current.is_some(), |ui| {
let mut pitches = Vec::new();
if let Some((d, p)) = current {
let coarse = metric_thread_sizes().iter().find(|(n, _)| *n == d).map(|(_, p)| *p);
pitches.extend(PITCHES.iter().copied().filter(|p| *p < d && *p <= coarse.unwrap_or(d)));
pitches.extend(coarse);
pitches.push(p);
}
pitches.sort_by(|a, b| b.total_cmp(a));
pitches.dedup();
let pitches = current.map(|(d, p)| pitches(f, d, p)).unwrap_or_default();
let labels: Vec<String> = pitches.iter().map(|p| format!("{p} mm")).collect();
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
let mut selected = current.and_then(|(_, p)| pitches.iter().position(|n| *n == p)).unwrap_or(0);
@@ -224,22 +509,70 @@ pub(crate) fn rows(ui: &mut egui::Ui, designation: &mut String, suggested: &mut
if selected != before
&& let (Some((d, _)), Some(p)) = (current, pitches.get(selected))
{
*designation = self::designation(d, *p);
*designation = designation_of(f, d, *p);
*suggested = None;
}
});
});
let (enter, changed) = text_row(ui, "Designation", designation, tr("fits the face, e.g. M8"));
if let Some(at) = crate::scenario::handle_at("dialog:Designation") {
crate::scenario::publish_handle("dialog:thread_size", at);
}
if changed {
*suggested = None;
}
(enter, reset)
}
/// Put the options into the command's parameters. Defaults are left out, so a thread without
/// them means what it always did.
pub(crate) fn put_params(p: &mut Value, length: &str, o: &Options, inputs: &[SelInput]) {
let f = &FORMS[o.form];
if o.form != 0 {
p["form"] = json!(f.value);
}
if o.hand != 0 {
p["hand"] = json!(HANDS[o.hand].1);
}
if o.starts != 0 {
p["starts"] = json!(o.starts + 1);
}
match o.extent {
LENGTH => {
p["extent"] = json!(EXTENTS[LENGTH].1);
put(p, &[("length", length)]);
}
UP_TO_EXTENT => {
p["extent"] = json!(EXTENTS[UP_TO_EXTENT].1);
if let Some(to) = up_to(inputs) {
p["up_to"] = to;
}
}
_ => {}
}
if let Some(from) = object_point(inputs, START_END) {
p["from"] = from;
}
for (key, e) in [("ease_in", &o.ease_in), ("ease_out", &o.ease_out)] {
match e.choice {
EASE_LENGTH => put(p, &[(key, e.length.as_str())]),
0 => {}
c => p[key] = json!(EASES[c].1),
}
}
if o.tip != 0 {
p["tip"] = json!(TIPS[o.tip].1);
put(p, &[("chamfer_angle", o.chamfer_angle.as_str()), ("chamfer_depth", o.chamfer_depth.as_str())]);
}
if f.buttress && o.steep_flank != 0 {
p["steep_flank"] = json!(FLANKS[o.steep_flank].1);
}
}
/// The picked Up To place: a plane's name, or a point on a face.
fn up_to(inputs: &[SelInput]) -> Option<Value> {
match inputs.iter().find(|i| i.label == UP_TO)?.items.first()? {
Sel::Plane { name } => Some(json!(name)),
_ => object_point(inputs, UP_TO),
}
}
#[cfg(test)]
#[path = "dialogs_thread_tests.rs"]
mod tests;
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod contribution_tests {
+245
View File
@@ -0,0 +1,245 @@
use serde_json::json;
use solidcraft_engine::geom::Vec3;
use super::*;
use crate::dialogs::{Kind, apply_commands, edge_sel};
use crate::preselect_tests::thread_face;
use crate::scenario::{self, Harness};
use crate::theme::Tokens;
/// A thread dialog on an 8 mm shaft 10 mm long, the face picked, in a window tall enough for
/// every row without scrolling the fields.
fn shaft() -> Harness {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
h.app.home.open = false;
h.app.run("solid.cylinder", json!({"radius":4,"height":10})).unwrap();
h.app.session.selection = vec![thread_face(&h.app, "Body1")];
h.step(&json!({"start":"solid.thread"})).unwrap();
h.frames(2);
h
}
fn click(h: &mut Harness, handles: &[&str]) {
for handle in handles {
h.step(&json!({"click":{"handle":handle}})).unwrap_or_else(|e| panic!("{handle}: {e}"));
}
h.frames(2);
}
fn choose(h: &mut Harness, row: &str, choice: &str) {
click(h, &[&format!("dialog:{row}"), &format!("dialog:{row}:{choice}")]);
}
fn type_in(h: &mut Harness, row: &str, text: &str) {
click(h, &[&format!("dialog:{row}")]);
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":text})).unwrap();
h.frames(2);
}
fn params(h: &Harness) -> serde_json::Value {
apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0).1
}
fn options(h: &Harness) -> Options {
match &h.app.dialog.as_ref().unwrap().kind {
Kind::Thread { options, .. } => options.clone(),
k => panic!("{k:?}"),
}
}
/// A dense row of the dialog, the same height as every other row.
fn shown(row: &str) -> bool {
let rect = scenario::rect_of(&format!("dialog:row:{row}"));
if let Some(rect) = rect {
assert_eq!(rect.height(), Tokens::get().control_height, "{row}");
}
rect.is_some()
}
/// Every option of the command, set in the dialog, reaches it under the command's names.
#[test]
fn every_option_reaches_the_command() {
let mut h = shaft();
let untouched = params(&h);
assert_eq!(untouched.as_object().unwrap().keys().collect::<Vec<_>>(), ["face"], "defaults stay out of the command: {untouched}");
choose(&mut h, "Form", "Trapezoidal (ISO 2904)");
choose(&mut h, "Size", "20 mm");
assert_eq!(params(&h)["designation"], "Tr20x4", "a new size takes its standard pitch");
choose(&mut h, "Pitch", "2 mm");
choose(&mut h, "Hand", "Left");
choose(&mut h, "Starts", "2");
choose(&mut h, "Extent", "Length");
type_in(&mut h, "Length", "6 mm");
choose(&mut h, "Ease In", "Standard (2.5 P)");
choose(&mut h, "Ease Out", "Length");
type_in(&mut h, "Ease Out Length", "1.5 mm");
choose(&mut h, "Tip", "Chamfer");
type_in(&mut h, "Chamfer Angle", "30 deg");
type_in(&mut h, "Chamfer Depth", "0.5 mm");
click(&mut h, &["dialog:Modeled"]);
let mut p = params(&h);
p.as_object_mut().unwrap().remove("face");
assert_eq!(
p,
json!({
"designation": "Tr20x2", "form": "trapezoidal", "hand": "left", "starts": 2, "extent": "length", "length": "6 mm",
"ease_in": "standard", "ease_out": "1.5 mm", "tip": "chamfer", "chamfer_angle": "30 deg", "chamfer_depth": "0.5 mm",
"modeled": true,
})
);
choose(&mut h, "Ease In", "Short (1.25 P)");
choose(&mut h, "Hand", "Both");
let p = params(&h);
assert_eq!((&p["ease_in"], &p["hand"]), (&json!("short"), &json!("both")));
}
/// Buttress rows only for the buttress forms, the length only with a Length extent, the Up To
/// pick only with an Up To extent, the chamfer only with a chamfered tip, sizes only for the
/// forms with a size list.
#[test]
fn only_the_fields_that_apply_are_shown() {
let mut h = shaft();
for row in ["Form", "Size", "Pitch", "Designation", "Hand", "Starts", "Extent", "Ease In", "Ease Out", "Tip"] {
assert!(shown(row), "{row}");
}
for row in ["Steep Flank", "Length", "Ease In Length", "Ease Out Length", "Chamfer Angle", "Chamfer Depth"] {
assert!(!shown(row), "{row}");
}
let inputs = |h: &Harness| h.app.dialog.as_ref().unwrap().inputs.iter().map(|i| i.label).collect::<Vec<_>>();
assert_eq!(inputs(&h), ["Cylindrical face"], "a full thread with plain ends starts at no end in particular");
choose(&mut h, "Form", "Buttress (ISO 3030)");
assert!(shown("Steep Flank") && shown("Size"));
choose(&mut h, "Steep Flank", "Start");
assert_eq!(params(&h)["steep_flank"], "start");
choose(&mut h, "Form", "Buttress (ASME B1.9)");
assert!(shown("Steep Flank") && !shown("Size") && !shown("Pitch"), "no size list for the ASME buttress");
assert_eq!(params(&h)["steep_flank"], "start");
choose(&mut h, "Form", "Square");
assert!(!shown("Steep Flank"));
assert!(params(&h).get("steep_flank").is_none(), "the steep flank is a buttress option");
choose(&mut h, "Extent", "Up to");
assert_eq!(inputs(&h), ["Cylindrical face", START_END, UP_TO]);
assert!(!shown("Length"));
choose(&mut h, "Extent", "Length");
assert_eq!(inputs(&h), ["Cylindrical face", START_END]);
assert!(shown("Length"));
choose(&mut h, "Extent", "Full");
assert_eq!(inputs(&h), ["Cylindrical face"]);
choose(&mut h, "Ease In", "Length");
assert_eq!(inputs(&h), ["Cylindrical face", START_END], "a runout starts at an end");
assert!(shown("Ease In Length") && !shown("Ease Out Length"));
choose(&mut h, "Tip", "Chamfer");
assert!(shown("Chamfer Angle") && shown("Chamfer Depth"));
let p = params(&h);
assert!(p.get("chamfer_angle").is_none() && p.get("chamfer_depth").is_none(), "empty values take ISO 4753's: {p}");
}
/// The picks: the end the thread starts from, and a plane or a face to run up to.
#[test]
fn the_start_end_and_up_to_picks_reach_the_command() {
let mut h = shaft();
choose(&mut h, "Extent", "Length");
let d = h.app.dialog.as_ref().unwrap();
assert_eq!((d.inputs[1].label, d.active), (START_END, 0), "the optional pick does not take the next click");
let top = edge_sel(&h.app.session, Vec3::new(4.0, 0.0, 10.0)).expect("the top edge");
let Sel::Edge { point, .. } = top else { panic!("{top:?}") };
h.app.dialog.as_mut().unwrap().inputs[1].items = vec![top];
assert_eq!(params(&h)["from"], json!([point.x, point.y, point.z]));
choose(&mut h, "Extent", "Up to");
let p = params(&h);
assert_eq!(p["extent"], "up_to");
assert!(p.get("up_to").is_none() && p.get("length").is_none());
let d = h.app.dialog.as_mut().unwrap();
assert_eq!(d.inputs[d.active].label, UP_TO, "the new pick is the active one");
d.inputs[2].items = vec![Sel::Plane { name: "XY".into() }];
assert_eq!(params(&h)["up_to"], "XY");
let face = crate::dialogs::face_sel(&h.app.session, Vec3::new(0.0, 0.0, 0.0)).expect("the bottom face");
let Sel::Face { point, .. } = face.clone() else { panic!("{face:?}") };
h.app.dialog.as_mut().unwrap().inputs[2].items = vec![face];
assert_eq!(params(&h)["up_to"], json!([point.x, point.y, point.z]));
choose(&mut h, "Extent", "Full");
let p = params(&h);
assert!(p.get("extent").is_none() && p.get("up_to").is_none() && p.get("from").is_none(), "{p}");
}
/// A form change keeps a manual size's diameter where the new form lists it, and the other
/// options; a form without that size, or a size the face chose, goes back to the face's size.
#[test]
fn a_form_change_keeps_what_still_applies() {
let mut h = shaft();
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation == "M8"));
choose(&mut h, "Hand", "Left");
choose(&mut h, "Starts", "3");
choose(&mut h, "Extent", "Length");
type_in(&mut h, "Length", "4 mm");
choose(&mut h, "Ease Out", "Short (1.25 P)");
let kept = options(&h);
choose(&mut h, "Form", "Trapezoidal (ISO 2904)");
assert!(params(&h).get("designation").is_none(), "the face's metric size is not carried to another form");
choose(&mut h, "Size", "20 mm");
choose(&mut h, "Form", "Buttress (ISO 3030)");
assert_eq!(params(&h)["designation"], "S20x4", "20 mm is a buttress size too");
choose(&mut h, "Form", "Trapezoidal (ISO 2904)");
choose(&mut h, "Size", "8 mm");
choose(&mut h, "Form", "Buttress (ISO 3030)");
assert!(params(&h).get("designation").is_none(), "8 mm is not a buttress size");
choose(&mut h, "Form", "ISO metric");
h.frames(2);
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation == "M8"), "the face's size again");
let now = options(&h);
assert_eq!(Options { form: kept.form, ..now.clone() }, kept);
let p = params(&h);
assert_eq!((&p["hand"], &p["starts"], &p["length"], &p["ease_out"]), (&json!("left"), &json!(3), &json!("4 mm"), &json!("short")));
}
/// The dialog shows the command's own error text for options it cannot build.
#[test]
fn the_command_error_is_what_the_dialog_shows() {
let mut h = shaft();
type_in(&mut h, "Designation", "Q7");
let (command, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
let saved = h.app.session.doc.to_json();
let expected = h.app.session.execute(&command, &params).expect_err("Q7 is no thread").to_string();
assert_eq!(h.app.session.doc.to_json(), saved);
h.step(&json!({"widget":"OK"})).unwrap();
h.frames(2);
let d = h.app.dialog.as_ref().expect("the dialog stays open");
assert!(d.error.as_deref().is_some_and(|e| expected.contains(e) || e.contains(&expected)), "{:?} vs {expected}", d.error);
}
#[test]
fn designations_name_their_form_size_and_pitch() {
let form = |value: &str| FORMS.iter().find(|f| f.value == value).unwrap();
assert_eq!(parse(form("trapezoidal"), "Tr20x4"), Some((20.0, 4.0)));
assert_eq!(parse(form("trapezoidal"), "Tr40x14(P7)"), Some((40.0, 7.0)), "a two-start lead 14 names pitch 7");
assert_eq!(parse(form("buttress"), "S20x4"), Some((20.0, 4.0)));
assert_eq!(parse(form("metric"), "M10x1"), Some((10.0, 1.0)));
assert_eq!(parse(form("trapezoidal"), "M10x1"), None);
assert_eq!(parse(form("acme"), "Tr20x4"), None);
for (designation, value) in [("M8", "metric"), ("Tr20x4", "trapezoidal"), ("S20x4", "buttress"), ("", "metric")] {
assert_eq!(FORMS[Options::for_saved(designation, "").form].value, value, "{designation}");
}
assert_eq!(Options::for_saved("M8", "4 mm").extent, LENGTH);
assert_eq!(pitches(form("trapezoidal"), 40.0, 7.0), [10.0, 7.0, 3.0]);
}
/// Editing a saved length thread reopens with a Length extent and its value.
#[test]
fn a_saved_thread_reopens_with_its_extent() {
let mut h = Harness::new();
h.app.run("solid.cylinder", json!({"radius":4,"height":10})).unwrap();
let Sel::Face { point, .. } = thread_face(&h.app, "Body1") else { panic!("a face") };
let id = h.app.run("solid.thread", json!({"face":point,"designation":"M8x1","length":"3 mm"})).unwrap()["feature"].as_u64().unwrap();
h.app.edit_feature(id);
h.frames(2);
assert_eq!(options(&h).extent, LENGTH);
assert!(shown("Length"));
let p = params(&h);
assert_eq!((&p["params"]["length"], &p["params"]["extent"], &p["params"]["designation"]), (&json!("3 mm"), &json!("length"), &json!("M8x1")));
}
+5 -4
View File
@@ -1179,7 +1179,7 @@ fn variable_fillet_mid_radii_round_trip() {
/// Use an actual mesh point, as a viewport pick does; analytic cylinder points need not lie
/// on the display mesh's facets.
fn thread_face(app: &SolveApp, body: &str) -> Sel {
pub(crate) fn thread_face(app: &SolveApp, body: &str) -> Sel {
let state = app.session.world_state().unwrap();
let body_state = state.body(body).unwrap();
let index = solidcraft_engine::doc::naming::face_spots(body_state)
@@ -1381,8 +1381,9 @@ fn modeled_thread_survives_timeline_double_click_and_ok() {
assert_eq!(h.app.dialog.as_ref().unwrap().editing, Some((id, None)));
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1, "the cylinder stays selected");
if change_length {
h.step(&json!({"click":{"handle":"dialog:Full Length"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Length"}})).unwrap();
for handle in ["dialog:Extent", "dialog:Extent:Length", "dialog:Length"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"2 mm"})).unwrap();
}
@@ -1459,7 +1460,7 @@ fn thread_choices_apply_a_fine_pitch_and_partial_modelled_thread() {
h.app.session.selection = vec![thread_face(&h.app, "Body1")];
h.step(&json!({"start":"solid.thread"})).unwrap();
let saved = h.app.session.doc.to_json();
for handle in ["dialog:Pitch", "dialog:Pitch:1 mm", "dialog:Full Length", "dialog:Length"] {
for handle in ["dialog:Pitch", "dialog:Pitch:1 mm", "dialog:Extent", "dialog:Extent:Length", "dialog:Length"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
h.step(&json!({"key":"A","cmd":true})).unwrap();