ui: the thread dialog takes its forms from the document and reopens every option

Sizes, designations and the face's size come from ThreadForm (sizes, parse,
format) and suggested_thread_in, per form; the dialog's own trapezoidal and
buttress lists are gone. Acme sizes are listed by designation. The square
thread, which the command refuses (no standard defines it), is not offered.
Editing a thread reopens its form, hand, starts, extent (length or up to, with
its pick), start end, runouts, tip and steep flank, and OK gives them back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-26 10:39:36 -04:00
parent fdede5a0ae
commit 986c649c98
4 changed files with 319 additions and 225 deletions
+5 -17
View File
@@ -1104,7 +1104,7 @@ impl Dialog {
/// 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, options, .. } = &mut self.kind else { return };
if !options.suggests() || !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) {
if !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();
@@ -1112,12 +1112,9 @@ impl Dialog {
return;
}
*of = picks.to_vec();
*suggested = picks.first().and_then(|sel| match sel {
Sel::Face { point, .. } => solidcraft_engine::doc::suggested_thread(&crate::open_meshing::session_world(s), *point).ok().map(|value| {
*suggested = crate::dialogs_thread::suggested(s, picks.map(|value| {
solidcraft_engine::doc::parse_metric_thread(&value).map(|(d, p)| crate::dialogs_thread::designation(d, p)).unwrap_or(value)
}),
_ => None,
});
}), options);
*designation = suggested.clone().unwrap_or_default();
}
@@ -5822,18 +5819,9 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Thread { face, designation, length, modeled, .. } => {
FeatureKind::Thread { .. } => {
let mut d = start("solid.thread")?;
if *modeled {
d.extra.insert("modeled".into(), json!(true));
}
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();
}
crate::dialogs_thread::reopen(&mut d, s, &f.kind);
d.sync_thread(s);
d
}
+238 -177
View File
@@ -1,22 +1,22 @@
//! 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.
//! freeze an automatic feature. Sizes, designations and the face's size come from the document's
//! thread forms ([`ThreadForm`]).
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 solidcraft_engine::doc::thread_forms::{SteepFlank, ThreadForm, ThreadHand, ThreadTip};
use solidcraft_engine::doc::{Expr, FeatureKind, ThreadHandedness, ThreadLimit, ThreadRunout};
use solidcraft_engine::{Sel, Session};
use crate::dialogs::{combo, form_row, object_point, put, set_object_accepting};
use crate::dialogs::{Dialog, Kind, combo, edge_sel, face_sel, form_row, object_point, plane_sel, put, set_object_accepting};
use crate::i18n::tr;
use crate::selection::{Accept, EDGES, FACES, PLANES, SelInput};
use crate::ui_kit::{choice_row, expr_row, hint_row, text_row};
/// 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];
/// Common metric pitch presets besides the coarse one, not an exhaustive catalogue of standard
/// diameter/pitch pairs; the current pitch is included separately, custom pairs remain editable.
const METRIC_PITCHES: &[f64] = &[0.2, 0.25, 0.35, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
/// Generated metric designations omit only the shared catalogue's coarse pitch.
/// Authored designation text is never rewritten just by displaying a dialog.
@@ -161,58 +161,17 @@ pub(crate) fn handedness_row(ui: &mut egui::Ui, handedness: &mut ThreadHandednes
*handedness = if selected == 1 { ThreadHandedness::Left } else { ThreadHandedness::Right };
}
/// 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]),
/// The forms offered. The square thread is left out: no standard defines it, and the command
/// refuses it ([`ThreadForm::refused`]).
const FORMS: &[(&str, ThreadForm)] = &[
("ISO metric", ThreadForm::Metric),
("Trapezoidal (ISO 2904)", ThreadForm::Trapezoidal),
("Acme (ASME B1.5)", ThreadForm::Acme),
("Buttress (DIN 513)", ThreadForm::Buttress),
("Buttress (ASME B1.9)", ThreadForm::ButtressAsme),
];
/// 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 HANDS: &[(&str, ThreadHand)] = &[("Right", ThreadHand::Right), ("Left", ThreadHand::Left), ("Both", ThreadHand::Both)];
const STARTS: &[(&str, u32)] = &[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("5", 5), ("6", 6), ("7", 7), ("8", 8)];
const FULL: usize = 0;
const LENGTH: usize = 1;
const UP_TO_EXTENT: usize = 2;
@@ -220,14 +179,13 @@ const EXTENTS: &[(&str, &str)] = &[("Full", "full"), ("Length", "length"), ("Up
/// 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")];
const TIPS: &[(&str, ThreadTip)] = &[("None", ThreadTip::None), ("Chamfer", ThreadTip::Chamfer)];
const FLANKS: &[(&str, SteepFlank)] = &[("End", SteepFlank::End), ("Start", SteepFlank::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.
@@ -235,15 +193,6 @@ 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 {
@@ -251,112 +200,118 @@ pub struct Ease {
pub length: String,
}
/// The thread options beyond the size and length (indices into this module's choice lists).
#[derive(Clone, Debug, Default, PartialEq)]
impl Ease {
fn of(r: &Option<ThreadRunout>) -> Ease {
match r {
None => Ease::default(),
Some(ThreadRunout::Standard) => Ease { choice: 1, length: String::new() },
Some(ThreadRunout::Short) => Ease { choice: 2, length: String::new() },
Some(ThreadRunout::Length { length }) => Ease { choice: EASE_LENGTH, length: expr(length) },
}
}
}
fn expr(e: &Expr) -> String {
e.to_string()
}
/// The thread options beyond the size and length.
#[derive(Clone, Debug, PartialEq)]
pub struct Options {
pub form: usize,
pub hand: usize,
/// The number of starts less one.
pub starts: usize,
pub form: ThreadForm,
pub hand: ThreadHand,
pub starts: u32,
/// An index into the extents (full, length, up to).
pub extent: usize,
pub ease_in: Ease,
pub ease_out: Ease,
pub tip: usize,
pub tip: ThreadTip,
pub chamfer_angle: String,
pub chamfer_depth: String,
pub steep_flank: usize,
pub steep_flank: SteepFlank,
}
impl Default for Options {
fn default() -> Options {
Options {
form: ThreadForm::Metric,
hand: ThreadHand::default(),
starts: 1,
extent: FULL,
ease_in: Ease::default(),
ease_out: Ease::default(),
tip: ThreadTip::default(),
chamfer_angle: String::new(),
chamfer_depth: String::new(),
steep_flank: SteepFlank::default(),
}
}
}
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
self.extent != FULL || self.ease_in.choice != 0 || self.ease_out.choice != 0 || self.tip != ThreadTip::None
}
}
/// 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
}
fn is_buttress(form: ThreadForm) -> bool {
matches!(form, ThreadForm::Buttress | ThreadForm::ButtressAsme)
}
/// 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(),
}
/// Sizes in inches are listed by designation, and their pitch is part of it (threads per inch).
fn inch(form: ThreadForm) -> bool {
matches!(form, ThreadForm::Acme | ThreadForm::ButtressAsme)
}
/// 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 same(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn designation_of(form: &Form, d: f64, p: f64) -> String {
format!("{}{d}x{p}", form.prefix.unwrap_or_default())
/// The diameter and pitch (mm) a designation of this form names.
fn parse(form: ThreadForm, designation: &str) -> Option<(f64, f64)> {
form.parse(designation, 1).ok().map(|t| (t.major, t.pitch))
}
fn designation_of(form: ThreadForm, d: f64, p: f64) -> String {
form.format(d, p, p)
}
/// 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);
fn pitches(form: ThreadForm, d: f64, current: f64) -> Vec<f64> {
let standard = form.sizes().into_iter().find(|(n, _)| same(*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());
if form == ThreadForm::Metric {
pitches.extend(METRIC_PITCHES.iter().copied().filter(|p| *p < d && *p <= standard.unwrap_or(d)));
}
pitches.extend(standard);
pitches.push(current);
pitches.sort_by(|a, b| b.total_cmp(a));
pitches.dedup();
pitches.dedup_by(|a, b| same(*a, *b));
pitches
}
/// A typical designation of the form, for the field's hint.
fn example(form: ThreadForm) -> String {
let sizes = form.sizes();
match sizes.iter().min_by(|a, b| (a.0 - 20.0).abs().total_cmp(&(b.0 - 20.0).abs())) {
Some((d, p)) => designation_of(form, *d, *p),
// ASME B1.9 has no size table: 2.5 in, 8 threads per inch.
None => designation_of(form, 2.5 * 25.4, 25.4 / 8.0),
}
}
/// 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 {
fn change_form(designation: &mut String, suggested: &mut Option<String>, from: ThreadForm, to: ThreadForm) -> bool {
let automatic = designation.trim().is_empty() || suggested.as_deref() == Some(designation.as_str());
let kept = (!automatic)
.then(|| parse(&FORMS[from], designation))
.then(|| parse(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)));
.and_then(|(d, _)| to.sizes().into_iter().find(|(n, _)| same(*n, d)))
.map(|(d, p)| designation_of(to, d, p));
*suggested = None;
match kept {
Some(kept) => {
@@ -370,6 +325,16 @@ fn change_form(designation: &mut String, suggested: &mut Option<String>, from: u
}
}
/// A choice row over typed values.
fn pick<T: Copy + PartialEq>(ui: &mut egui::Ui, label: &str, id: &str, value: &mut T, choices: &[(&str, T)]) {
let labels: Vec<&str> = choices.iter().map(|c| c.0).collect();
let mut index = choices.iter().position(|c| c.1 == *value).unwrap_or(0);
choice_row(ui, label, id, &mut index, &labels);
if let Some(c) = choices.get(index) {
*value = c.1;
}
}
/// 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(
@@ -382,14 +347,13 @@ pub(crate) fn rows(
) -> (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);
pick(ui, "Form", "thread_form", &mut form, FORMS);
if form != o.form {
reset |= change_form(designation, suggested, o.form, form);
o.form = form;
}
let f = &FORMS[o.form];
let f = o.form;
let before = designation.trim().is_empty() || suggested.as_deref() == Some(designation.as_str());
let mut automatic = before;
@@ -405,13 +369,10 @@ pub(crate) fn rows(
}
}
if f.prefix.is_some() {
if !f.sizes().is_empty() {
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 hint = format!("{}{}", tr("fits the face, e.g. "), example(f));
let (submitted, changed) = text_row(ui, "Designation", designation, &hint);
enter |= submitted;
if let Some(at) = crate::scenario::handle_at("dialog:Designation") {
@@ -421,12 +382,10 @@ pub(crate) fn rows(
*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);
pick(ui, "Hand", "thread_hand", &mut o.hand, HANDS);
pick(ui, "Starts", "thread_starts", &mut o.starts, STARTS);
if is_buttress(f) {
pick(ui, "Steep Flank", "thread_flank", &mut o.steep_flank, FLANKS);
}
let extents: Vec<&str> = EXTENTS.iter().map(|x| x.0).collect();
@@ -442,9 +401,8 @@ pub(crate) fn rows(
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 {
pick(ui, "Tip", "thread_tip", &mut o.tip, TIPS);
if o.tip == ThreadTip::Chamfer {
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"));
}
@@ -466,20 +424,28 @@ fn ease_rows(ui: &mut egui::Ui, label: &'static str, id: &str, e: &mut Ease) ->
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);
/// The Size choice of a form with a size table, and the Pitch choice of the metric ones (an
/// inch designation names its threads per inch).
fn size_rows(ui: &mut egui::Ui, f: ThreadForm, designation: &mut String, suggested: &mut Option<String>) {
let table = f.sizes();
let current = parse(f, designation);
let mut diameters: Vec<f64> = table.iter().map(|(d, _)| *d).collect();
if let Some((d, _)) = current
&& !diameters.contains(&d)
&& !diameters.iter().any(|n| same(*n, d))
{
diameters.push(d);
diameters.sort_by(f64::total_cmp);
}
form_row(ui, "Size", |ui| {
let labels: Vec<String> = diameters.iter().map(|d| format!("{d} mm")).collect();
let mut selected = current.and_then(|(d, _)| diameters.iter().position(|n| *n == d)).unwrap_or(diameters.len());
let labels: Vec<String> = diameters
.iter()
.map(|d| match table.iter().find(|(n, _)| same(*n, *d)) {
Some((n, p)) if inch(f) => designation_of(f, *n, *p),
_ if inch(f) => designation.trim().to_string(),
_ => format!("{d} mm"),
})
.collect();
let mut selected = current.and_then(|(d, _)| diameters.iter().position(|n| same(*n, d))).unwrap_or(diameters.len());
let before = selected;
let mut labels: Vec<&str> = labels.iter().map(String::as_str).collect();
if current.is_none() {
@@ -490,12 +456,15 @@ fn size_rows(ui: &mut egui::Ui, f: &Form, designation: &mut String, suggested: &
// is already selected and retains its explicit pitch.
if selected != before
&& let Some(d) = diameters.get(selected)
&& let Some(p) = table.iter().find(|(n, _)| n == d).and_then(|(_, p)| p.first())
&& let Some((_, p)) = table.iter().find(|(n, _)| same(*n, *d))
{
*designation = designation_of(f, *d, *p);
*suggested = None;
}
});
if inch(f) {
return;
}
let current = parse(f, designation);
form_row(ui, "Pitch", |ui| {
@@ -503,7 +472,7 @@ fn size_rows(ui: &mut egui::Ui, f: &Form, designation: &mut String, suggested: &
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);
let mut selected = current.and_then(|(_, p)| pitches.iter().position(|n| same(*n, p))).unwrap_or(0);
let before = selected;
combo(ui, "thread_pitch", &labels, &mut selected);
if selected != before
@@ -516,18 +485,26 @@ fn size_rows(ui: &mut egui::Ui, f: &Form, designation: &mut String, suggested: &
});
}
/// 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);
}
/// 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.form != ThreadForm::Metric {
p["form"] = json!(o.form.name());
}
if o.hand != 0 {
p["hand"] = json!(HANDS[o.hand].1);
if o.hand != ThreadHand::default() {
p["hand"] = json!(o.hand);
}
if o.starts != 0 {
p["starts"] = json!(o.starts + 1);
if o.starts != 1 {
p["starts"] = json!(o.starts);
}
match o.extent {
LENGTH => {
@@ -552,12 +529,12 @@ pub(crate) fn put_params(p: &mut Value, length: &str, o: &Options, inputs: &[Sel
c => p[key] = json!(EASES[c].1),
}
}
if o.tip != 0 {
p["tip"] = json!(TIPS[o.tip].1);
if o.tip != ThreadTip::None {
p["tip"] = json!(o.tip);
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);
if is_buttress(o.form) && o.steep_flank != SteepFlank::default() {
p["steep_flank"] = json!(o.steep_flank);
}
}
@@ -569,6 +546,90 @@ fn up_to(inputs: &[SelInput]) -> Option<Value> {
}
}
/// The face's size in the dialog's form, for an automatic size.
pub(crate) fn suggested(s: &Session, picks: &[Sel], o: &Options) -> Option<String> {
picks.first().and_then(|sel| match sel {
Sel::Face { point, .. } => solidcraft_engine::doc::suggested_thread_in(&crate::open_meshing::session_world(s), *point, o.form).ok(),
_ => None,
})
}
/// A saved thread's dialog: every option as saved, with its picks.
pub(crate) fn reopen(d: &mut Dialog, s: &Session, kind: &FeatureKind) {
let FeatureKind::Thread {
face,
designation,
length,
modeled,
from,
up_to,
form,
hand,
starts,
ease_in,
ease_out,
tip,
chamfer_angle,
chamfer_depth,
steep_flank,
} = kind
else {
return;
};
if *modeled {
d.extra.insert("modeled".into(), json!(true));
}
let options = Options {
form: *form,
hand: *hand,
starts: *starts,
extent: if length.is_some() {
LENGTH
} else if up_to.is_some() {
UP_TO_EXTENT
} else {
FULL
},
ease_in: Ease::of(ease_in),
ease_out: Ease::of(ease_out),
tip: *tip,
chamfer_angle: chamfer_angle.as_ref().map(expr).unwrap_or_default(),
chamfer_depth: chamfer_depth.as_ref().map(expr).unwrap_or_default(),
steep_flank: *steep_flank,
};
if let Some(inp) = d.inputs.first_mut() {
inp.items = face_sel(s, face.0).into_iter().collect();
}
sync_inputs(&mut d.inputs, &mut d.active, &options);
// A place no pick finds again (the model changed) is still the place saved.
let place = |sel: Option<Sel>, p| sel.unwrap_or(Sel::Vertex { body: String::new(), point: p });
let set = |d: &mut Dialog, label: &str, sel: Sel| {
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) {
inp.items = vec![sel];
}
};
if let Some(from) = from {
set(d, START_END, place(edge_sel(s, from.0), from.0));
}
match up_to {
Some(ThreadLimit::Face(at)) => set(d, UP_TO, place(face_sel(s, at.0), at.0)),
Some(ThreadLimit::Plane(plane)) => {
if let Some(sel) = plane_sel(s, plane) {
set(d, UP_TO, sel);
}
}
None => {}
}
d.active = 0;
d.kind = Kind::Thread {
designation: designation.clone().unwrap_or_default(),
length: length.as_ref().map(expr).unwrap_or_default(),
suggested: None,
of: Vec::new(),
options,
};
}
#[cfg(test)]
#[path = "dialogs_thread_tests.rs"]
mod tests;
+71 -31
View File
@@ -67,7 +67,8 @@ fn every_option_reaches_the_command() {
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");
type_in(&mut h, "Designation", "Tr20x2");
choose(&mut h, "Pitch", "4 mm");
choose(&mut h, "Hand", "Left");
choose(&mut h, "Starts", "2");
choose(&mut h, "Extent", "Length");
@@ -84,7 +85,7 @@ fn every_option_reaches_the_command() {
assert_eq!(
p,
json!({
"designation": "Tr20x2", "form": "trapezoidal", "hand": "left", "starts": 2, "extent": "length", "length": "6 mm",
"designation": "Tr20x4", "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,
})
@@ -110,15 +111,15 @@ fn only_the_fields_that_apply_are_shown() {
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)");
choose(&mut h, "Form", "Buttress (DIN 513)");
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"));
choose(&mut h, "Form", "Acme (ASME B1.5)");
assert!(!shown("Steep Flank") && shown("Size") && !shown("Pitch"), "an Acme size names its threads per inch");
assert!(params(&h).get("steep_flank").is_none(), "the steep flank is a buttress option");
choose(&mut h, "Extent", "Up to");
@@ -183,11 +184,11 @@ fn a_form_change_keeps_what_still_applies() {
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)");
choose(&mut h, "Form", "Buttress (DIN 513)");
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)");
choose(&mut h, "Form", "Buttress (DIN 513)");
assert!(params(&h).get("designation").is_none(), "8 mm is not a buttress size");
choose(&mut h, "Form", "ISO metric");
h.frames(2);
@@ -214,32 +215,71 @@ fn the_command_error_is_what_the_dialog_shows() {
}
#[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]);
fn the_forms_are_the_documents_and_square_is_not_offered() {
assert!(FORMS.iter().all(|(_, f)| f.refused().is_none()), "a form the command refuses is not offered");
assert_eq!(FORMS.len(), ThreadForm::ALL.len() - 1);
assert_eq!(parse(ThreadForm::Trapezoidal, "Tr40x14(P7)"), Some((40.0, 7.0)), "a two-start lead 14 names pitch 7");
assert_eq!(parse(ThreadForm::Trapezoidal, "M10x1"), None);
assert_eq!(pitches(ThreadForm::Metric, 8.0, 1.25), [1.25, 1.0, 0.75, 0.5, 0.35, 0.25, 0.2]);
assert_eq!(example(ThreadForm::ButtressAsme), "2.5-8 BUTT");
assert_eq!(example(ThreadForm::Trapezoidal), "Tr20x4");
}
/// Editing a saved length thread reopens with a Length extent and its value.
/// The face's size follows the form.
#[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);
fn the_face_size_follows_the_form() {
let mut h = shaft();
let shown_size = |h: &Harness| match &h.app.dialog.as_ref().unwrap().kind {
Kind::Thread { designation, .. } => designation.clone(),
k => panic!("{k:?}"),
};
assert_eq!(shown_size(&h), "M8");
choose(&mut h, "Form", "Trapezoidal (ISO 2904)");
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")));
assert_eq!(shown_size(&h), "Tr8x1.5");
assert!(params(&h).get("designation").is_none(), "the face's size stays automatic");
}
/// Editing a saved thread reopens every option it was made with, and OK gives them back.
#[test]
fn a_saved_thread_reopens_with_every_option() {
for (made, picks) in [
(
json!({"designation":"S20x4","steep_flank":"start","hand":"left","starts":2,"length":"12 mm","from":[10,0,20],"modeled":true,
"ease_in":"standard","ease_out":"1 mm","tip":"chamfer","chamfer_angle":"30 deg","chamfer_depth":"0.5 mm"}),
vec!["Cylindrical face", START_END],
),
(json!({"designation":"M20","from":[10,0,20],"up_to":[10,0,5]}), vec!["Cylindrical face", START_END, UP_TO]),
(json!({"designation":"M20","up_to":"XY","from":[10,0,20]}), vec!["Cylindrical face", START_END, UP_TO]),
] {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
h.app.run("solid.cylinder", json!({"radius":10,"height":20})).unwrap();
let Sel::Face { point, .. } = thread_face(&h.app, "Body1") else { panic!("a face") };
let mut command = made.clone();
command["face"] = json!([point.x, point.y, point.z]);
let id = h.app.run("solid.thread", command).unwrap_or_else(|e| panic!("{made}: {e}"))["feature"].as_u64().unwrap();
h.app.edit_feature(id);
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), picks, "{made}");
let p = params(&h)["params"].clone();
for (key, value) in made.as_object().unwrap() {
match key.as_str() {
"from" | "up_to" if value.is_array() => {
let near = |a: &Value, b: &Value| (0..3).all(|i| (a[i].as_f64().unwrap() - b[i].as_f64().unwrap()).abs() < 0.5);
assert!(near(&p[key], value), "{key}: {} vs {value}", p[key]);
}
_ => assert_eq!(&p[key], value, "{key} in {p}"),
}
}
let extent = if made.get("length").is_some() { "length" } else { "up_to" };
assert_eq!(p["extent"], extent);
if made["designation"] == "S20x4" {
assert_eq!(p["form"], "buttress", "the form is saved resolved");
assert!(shown("Steep Flank") && shown("Chamfer Angle") && shown("Ease Out Length"));
}
let (cmd, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
h.app.session.execute(&cmd, &params).unwrap_or_else(|e| panic!("{made}: OK on the reopened dialog: {e}"));
}
}
+5
View File
@@ -1386,6 +1386,10 @@ fn modeled_thread_survives_timeline_double_click_and_ok() {
}
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"2 mm"})).unwrap();
// Both ends of the shaft are free: a length starts from the end picked.
let top = crate::dialogs::edge_sel(&h.app.session, Vec3::new(4.0, 0.0, 4.0)).unwrap();
let d = h.app.dialog.as_mut().unwrap();
d.inputs.iter_mut().find(|i| i.label == "Start end (optional)").unwrap().items = vec![top];
}
if h.app.dialog.as_ref().unwrap().previews() {
wait_feature_preview(&mut h);
@@ -1550,6 +1554,7 @@ fn thread_choices_can_start_without_a_face_and_recover_from_invalid_text() {
h.call("ui.scroll", json!({"x":first.x,"y":first.y,"delta":-60}));
h.frames(12);
h.step(&json!({"click":{"handle":"dialog:Size:8 mm"}})).unwrap();
// The coarse pitch is written as ISO 261 writes it: without the pitch.
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation == "M8"));
if let Kind::Thread { designation, .. } = &h.app.dialog.as_ref().unwrap().kind {
assert_eq!(solidcraft_engine::doc::parse_metric_thread(designation), Some((8.0, 1.25)));