Thread: suggest cylinder size and expose metric diameter and pitch (#1759)

Thanks @cfaulkingham.
This commit is contained in:
Colin Faulkingham authored and GitHub committed 2026-10-16 12:26:23 -04:00
1 parent 741265f214
commit da65b4fc8c
12 files changed
+418 -18

No files matched your search

+5
View File
@@ -80,6 +80,11 @@ completed drawings publish atomically. The browser still exports synchronously.
## Interactive modelling
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
pitch selectors expose the existing metric designation parameter; custom designations stay
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
full/partial-length options remain available. This does not add tolerance classes or
left-hand threads.
- New Body extrusion and face-offset previews show completed intermediate geometry while a
drag continues, then catch up to the exact final value. Changes to the selected geometry,
operation or feature still discard obsolete results. Distance arrows and their value boxes
+4
View File
@@ -783,6 +783,10 @@
"sketch_text.path_hint": "A curve of the sketch the text follows: click here, then the curve",
"sketch_text.use_font_file": "Use the font file {path}",
"the parts library is not available": "the parts library is not available",
"thread.automatic_size": "Automatic size",
"thread.designation": "Designation",
"thread.iso_metric": "ISO metric",
"thread.select_size": "Select size",
"timeline.group": "New Feature Folder",
"timeline.move_many": "Move Timeline Items",
"timeline.move_to_group": "Move Features to Folder",
+4
View File
@@ -783,6 +783,10 @@
"sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック",
"sketch_text.use_font_file": "フォント ファイル {path} を使用",
"the parts library is not available": "パーツライブラリは利用できません",
"thread.automatic_size": "サイズを自動選択",
"thread.designation": "ねじの呼び",
"thread.iso_metric": "ISOメートルねじ",
"thread.select_size": "サイズを選択",
"timeline.group": "フィーチャーフォルダを新規作成",
"timeline.move_many": "タイムライン項目を移動",
"timeline.move_to_group": "フィーチャーをフォルダへ移動",
+4
View File
@@ -783,6 +783,10 @@
"sketch_text.path_hint": "文字所跟随的草图曲线:先单击此处,再选择曲线",
"sketch_text.use_font_file": "使用字体文件 {path}",
"the parts library is not available": "零件库不可用",
"thread.automatic_size": "自动尺寸",
"thread.designation": "螺纹标记",
"thread.iso_metric": "ISO 公制",
"thread.select_size": "选择尺寸",
"timeline.group": "新建特征文件夹",
"timeline.move_many": "移动时间线项目",
"timeline.move_to_group": "将特征移至文件夹",
+13 -1
View File
@@ -209,6 +209,12 @@ const ISO_COARSE: [(f64, f64); 22] = [
(56.0, 5.5),
];
/// Nominal diameters and coarse pitches (mm) used for automatic ISO metric thread sizing.
/// Other diameter/pitch pairs can be supplied as explicit designations.
pub fn metric_thread_sizes() -> &'static [(f64, f64)] {
&ISO_COARSE
}
/// "M8" or "M8x1" → (nominal diameter, pitch).
pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> {
let t = s.trim().to_ascii_uppercase();
@@ -221,7 +227,7 @@ pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> {
Some(p) => p,
None => ISO_COARSE.iter().find(|(n, _)| (n - d).abs() < 1e-9)?.1,
};
(d > 0.0 && pitch > 0.0 && pitch < d).then_some((d, pitch))
(d.is_finite() && pitch.is_finite() && d > 0.0 && pitch > 0.0 && pitch < d).then_some((d, pitch))
}
/// The metric thread a cylinder of this diameter takes: a shaft is the major diameter, a hole
@@ -241,6 +247,12 @@ fn format_thread(d: f64, p: f64) -> String {
if coarse { format!("M{}", num(d)) } else { format!("M{}x{}", num(d), num(p)) }
}
/// The automatic ISO metric designation for a cylindrical face, using the same fit check as
/// a thread feature without an explicit size. This query does not change the model.
pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result<String> {
thread_on(st, 0, p, None, None).map(|t| t.designation)
}
/// A cosmetic thread on the cylindrical face at `p`.
fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>) -> Result<ThreadInfo> {
let (body, cyl) = st
+3 -3
View File
@@ -58,9 +58,9 @@ pub use assembly::{
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices,
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at,
through_all_distance, world_state, world_state_reusing,
edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, object_height, parse_metric_thread,
pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame,
state_in_frame_at, suggested_thread, through_all_distance, world_state, world_state_reusing,
};
pub use expr::Expr;
pub use names::{BodyName, FeatureName, SketchId};
+1 -1
View File
@@ -79,7 +79,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
+12
View File
@@ -22,6 +22,18 @@ fn volume(s: &mut Session, b: &str) -> f64 {
const SIZES: [(f64, f64); 9] = [(3.0, 0.5), (4.0, 0.7), (5.0, 0.8), (6.0, 1.0), (8.0, 1.25), (10.0, 1.5), (12.0, 1.75), (16.0, 2.0), (20.0, 2.5)];
#[test]
fn non_finite_thread_designations_are_refused_without_changing_the_design() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":4,"height":10}));
let saved = s.doc.to_json();
for designation in ["Minfx1", "M8xinf", "MNaNx1", "M8xNaN", "M1e999x1"] {
let result = s.execute("solid.thread", &json!({"face":[4,0,2],"designation":designation}));
assert!(result.is_err(), "{designation}: {result:?}");
assert_eq!(s.doc.to_json(), saved);
}
}
/// ∫ 2π r · width(r) dr over [a, b]: the volume one turn of the groove takes out (a screw
/// motion of a section in an axial plane sweeps 2π∫r dA per turn).
fn per_turn(a: f64, b: f64, width: impl Fn(f64) -> f64) -> f64 {
+47 -13
View File
@@ -137,6 +137,11 @@ pub enum Kind {
Thread {
designation: String,
length: String,
/// The last automatic size; a different value is a manual override. Keep automatic
/// features automatic when applying, even though their resolved size is visible.
suggested: Option<String>,
/// The picks used for the suggestion, so an unchanged dialog does no geometry work.
of: Vec<Sel>,
},
/// A rib (one wall along open curves) or a web (a wall per curve).
Rib {
@@ -529,9 +534,10 @@ impl Dialog {
),
"solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]),
"solid.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]),
"solid.thread" => {
Dialog::new(Kind::Thread { designation: String::new(), length: String::new() }, vec![SelInput::new("Cylindrical face", FACES, false)])
}
"solid.thread" => Dialog::new(
Kind::Thread { designation: String::new(), length: String::new(), suggested: None, of: Vec::new() },
vec![SelInput::new("Cylindrical face", FACES, false)],
),
"material.assign" => Dialog::new(Kind::Material { index: 1 }, vec![SelInput::new("Bodies", BODIES, true)]),
"inspect.section" => {
Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)])
@@ -700,10 +706,30 @@ impl Dialog {
{
inp.items = default_profiles(s);
}
d.sync_thread(s);
d.advance();
Some(d)
}
/// 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()) {
return;
}
let picks = self.inputs.first().map(|i| i.items.as_slice()).unwrap_or_default();
if picks == of.as_slice() {
return;
}
*of = picks.to_vec();
*suggested = picks.first().and_then(|sel| match sel {
Sel::Face { point, .. } => solidcraft_engine::doc::suggested_thread(&s.world_state(), *point).ok(),
_ => None,
});
*designation = suggested.clone().unwrap_or_default();
}
pub fn rename(feature: u64, name: &str) -> Dialog {
Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![])
}
@@ -946,6 +972,7 @@ impl Dialog {
self.advance();
}
self.error = None;
self.sync_thread(s);
}
/// Box selection result: replace (or add to) the active input.
@@ -976,6 +1003,7 @@ impl Dialog {
inp.items.push(x);
}
}
self.sync_thread(s);
}
}
@@ -1712,6 +1740,7 @@ fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::
}
d.active = i;
}
d.sync_thread(s);
}
/// Each live reference sits with the independent values it drives.
@@ -2110,6 +2139,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
auto_operation(app, &mut d);
d.sync_thread(&app.session);
update_measure(app, &mut d);
update_fill_cells(app, &mut d);
// A refused OK's error is about the inputs it ran with (#641). Once a value (in the dialog,
@@ -2750,22 +2780,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
enter |= field(ui, distance);
ui.end_row();
}
Kind::Thread { designation, length } => {
// Fusion's order: Modeled, Full Length, then the size; a length only when not
// full.
Kind::Thread { designation, length, suggested, of } => {
row_label(ui, "Modeled");
extra_check(ui, &mut d.extra, "modeled");
ui.end_row();
row_label(ui, "Full Length");
let mut full = length.trim().is_empty();
if ui.checkbox(&mut full, "").changed() {
let response = ui.checkbox(&mut full, "");
publish_row(&response);
if response.changed() {
*length = if full { String::new() } else { "10 mm".into() };
}
ui.end_row();
row_label(ui, "Size");
let r = ui.add(egui::TextEdit::singleline(designation).hint_text(tr("fits the face, e.g. M8")));
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
let (submitted, reset) = crate::dialogs_thread::rows(ui, designation, suggested);
enter |= submitted;
if reset {
of.clear();
}
if !full {
row_label(ui, "Length");
enter |= field(ui, length);
@@ -4030,10 +4061,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
need(0, "planar faces")?;
("solid.offset_face", json!({"faces": face_points(0), "distance": distance}))
}
Kind::Thread { designation, length } => {
Kind::Thread { designation, length, suggested, .. } => {
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() {
if !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) {
p["designation"] = json!(designation.trim());
}
if !length.trim().is_empty() {
@@ -5058,10 +5089,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.kind = Kind::Thread {
designation: designation.clone().unwrap_or_default(),
length: length.as_deref().map(str::to_string).unwrap_or_default(),
suggested: None,
of: Vec::new(),
};
if let Some(inp) = d.inputs.first_mut() {
inp.items = face_sel(s, face.0).into_iter().collect();
}
d.sync_thread(s);
d
}
FeatureKind::BoundingSolid { bodies, margin } => {
+95
View File
@@ -0,0 +1,95 @@
//! Metric thread choices use the document's sizing table and the existing designation
//! parameter. Browsing choices does not edit the model or freeze an automatic feature.
use solidcraft_engine::doc::{metric_thread_sizes, parse_metric_thread};
use crate::dialogs::{check, combo, row_label};
use crate::i18n::tr;
/// Common 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];
/// Returns (Enter pressed, automatic sizing requested).
pub(crate) fn rows(ui: &mut egui::Ui, designation: &mut String, suggested: &mut Option<String>) -> (bool, bool) {
row_label(ui, "Profile");
ui.label(tr("ISO metric"));
ui.end_row();
row_label(ui, "Automatic size");
let before = designation.trim().is_empty() || suggested.as_deref() == Some(designation.as_str());
let mut automatic = before;
ui.add_enabled_ui(!designation.trim().is_empty(), |ui| check(ui, &mut automatic));
let reset = automatic && !before;
if automatic != before {
*suggested = None;
if automatic {
designation.clear();
}
}
ui.end_row();
let current = parse_metric_thread(designation);
let mut diameters: Vec<f64> = metric_thread_sizes().iter().map(|(d, _)| *d).collect();
if let Some((d, _)) = current
&& !diameters.contains(&d)
{
diameters.push(d);
diameters.sort_by(f64::total_cmp);
}
row_label(ui, "Size");
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 before = selected;
let mut labels: Vec<&str> = labels.iter().map(String::as_str).collect();
if current.is_none() {
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,
// 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)
{
*designation = format!("M{d}x{coarse}");
*suggested = None;
}
ui.end_row();
let current = parse_metric_thread(designation);
row_label(ui, "Pitch");
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 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 before = selected;
combo(ui, "thread_pitch", &labels, &mut selected);
if selected != before
&& let (Some((d, _)), Some(p)) = (current, pitches.get(selected))
{
*designation = format!("M{d}x{p}");
*suggested = None;
}
});
ui.end_row();
row_label(ui, "Designation");
let response = ui.add(egui::TextEdit::singleline(designation).hint_text(tr("fits the face, e.g. M8")));
crate::scenario::publish_handle("dialog:thread_size", response.rect.center());
if response.changed() {
*suggested = None;
}
let enter = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
(enter, reset)
}
+1
View File
@@ -36,6 +36,7 @@ pub mod dialogs_save_mesh;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dialogs_sweep;
mod dialogs_thread;
pub mod dialogs_view_dxf;
pub mod dim_view;
#[cfg(test)]
+229
View File
@@ -905,6 +905,235 @@ fn variable_fillet_mid_radii_round_trip() {
assert_eq!(p["params"]["mid"], json!([]), "{p}");
}
/// 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 {
let state = app.session.world_state();
let body_state = state.body(body).unwrap();
let index = solidcraft_engine::doc::naming::face_spots(body_state)
.into_iter()
.enumerate()
.find_map(|(index, spot)| {
spot.filter(|spot| matches!(spot.surface, Some(solidcraft_engine::kernel::FaceSurface::Cylinder { .. }))).map(|_| index)
})
.unwrap();
let mesh = body_state.mesh();
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.find_map(|(triangle, face)| {
if *face as usize != index {
return None;
}
mesh.tri(triangle).map(|[a, b, c]| (a + b + c) / 3.0)
})
.unwrap();
Sel::Face { body: body.into(), index, point }
}
#[test]
fn thread_size_suggestion_matches_the_feature_for_shafts_and_holes() {
for (diameter, internal, expected) in [(8.0, false, "M8"), (20.0, false, "M20"), (5.0, true, "M6")] {
let mut app = app_with(json!([]));
if internal {
app.run("solid.box", json!({"corner":[-10,-10,0],"length":20,"width":20,"height":10})).unwrap();
}
app.run("solid.cylinder", json!({"radius":diameter / 2.0,"height":10,"body_name":"Cylinder"})).unwrap();
if internal {
app.run("solid.combine", json!({"target":"Body1","tools":["Cylinder"],"operation":"cut"})).unwrap();
}
let pick = thread_face(&app, if internal { "Body1" } else { "Cylinder" });
let saved = app.session.doc.to_json();
let d = start(&mut app, vec![pick], "solid.thread");
assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == expected), "{:?}", d.kind);
assert_eq!(app.session.doc.to_json(), saved, "suggesting a size is read-only");
let (command, params) = apply_commands(&app, &d).unwrap().remove(0);
assert!(params.get("designation").is_none(), "an automatic size stays automatic");
let result = app.session.execute(&command, &params).unwrap();
assert_eq!(result["thread"]["designation"], expected);
assert_eq!(result["thread"]["internal"], internal);
}
}
#[test]
fn thread_size_suggestion_follows_picks_without_overwriting_manual_sizes() {
let mut app = app_with(json!([
{"command":"solid.cylinder","params":{"radius":4,"height":10}},
{"command":"solid.cylinder","params":{"base":[30,0,0],"radius":10,"height":10}},
{"command":"solid.cylinder","params":{"base":[200,0,0],"radius":50,"height":10}}
]));
let small = thread_face(&app, "Body1");
let large = thread_face(&app, "Body2");
let unsupported = thread_face(&app, "Body3");
let planar = crate::dialogs::face_sel(&app.session, Vec3::new(0.0, 0.0, 10.0)).unwrap();
let mut d = start(&mut app, vec![], "solid.thread");
for (pick, expected) in [
(small.clone(), "M8"),
(large.clone(), "M20"),
(large.clone(), "M20"),
(small.clone(), "M8"),
(unsupported, ""),
(small.clone(), "M8"),
(planar, ""),
] {
d.pick(&app.session, pick);
assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == expected), "{:?}", d.kind);
}
d.take_box(&app.session, vec![], false);
assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation.is_empty()));
d.take_box(&app.session, vec![small], false);
assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == "M8"));
if let Kind::Thread { designation, .. } = &mut d.kind {
*designation = "M8x1".into();
}
d.pick(&app.session, large);
assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == "M8x1"));
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["designation"], "M8x1");
}
#[test]
fn thread_size_suggestion_reopens_without_changing_saved_thread_parameters() {
for designation in [None, Some("M8"), Some("M8x1")] {
let mut h = crate::scenario::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 mut params = json!({"face":point});
if let Some(size) = designation {
params["designation"] = json!(size);
}
let id = h.app.run("solid.thread", params).unwrap()["feature"].as_u64().unwrap();
let before = h.app.session.doc.feature(id).unwrap().kind.clone();
h.app.edit_feature(id);
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(&d.kind, Kind::Thread { designation: size, .. } if size == designation.unwrap_or("M8")), "{d:?}");
let (command, params) = apply_commands(&h.app, d).unwrap().remove(0);
h.app.session.execute(&command, &params).unwrap();
assert_eq!(h.app.session.doc.feature(id).unwrap().kind, before);
}
}
#[test]
fn thread_size_suggestion_can_be_cleared_and_edited_in_the_dialog() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.cylinder", json!({"radius":4,"height":10})).unwrap();
let pick = thread_face(&h.app, "Body1");
h.app.session.selection = vec![pick.clone()];
h.step(&json!({"start":"solid.thread"})).unwrap();
h.step(&json!({"click":{"handle":"dialog:clear:Cylindrical face"}})).unwrap();
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation.is_empty()));
h.app.dialog.as_mut().unwrap().pick(&h.app.session, pick.clone());
h.frames(2);
h.step(&json!({"click":{"handle":"dialog:thread_size"}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
// Even deliberately typing the same size turns it into an explicit override.
h.step(&json!({"text":"M8"})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(apply_commands(&h.app, d).unwrap()[0].1["designation"], "M8");
h.step(&json!({"click":{"handle":"dialog:clear:Cylindrical face"}})).unwrap();
h.app.dialog.as_mut().unwrap().pick(&h.app.session, pick);
h.frames(2);
assert_eq!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["designation"], "M8");
}
#[test]
fn thread_choices_apply_a_fine_pitch_and_partial_modelled_thread() {
let mut h = crate::scenario::Harness::new();
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();
let saved = h.app.session.doc.to_json();
for handle in ["dialog:Pitch", "dialog:Pitch:1 mm", "dialog:Full Length", "dialog:Length"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"4 mm"})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Modeled"}})).unwrap();
let (command, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
assert_eq!(params["designation"], "M8x1");
assert_eq!(params["length"], "4 mm");
assert_eq!(params["modeled"], true);
assert_eq!(h.app.session.doc.to_json(), saved, "changing options only previews");
let before = h.app.session.world_state().body("Body1").unwrap().mesh().measure().volume;
let result = h.app.session.execute(&command, &params).unwrap();
let state = h.app.session.world_state();
let after = state.body("Body1").unwrap().mesh().measure().volume;
assert!(before - after > 1.0, "the selected fine pitch must cut the shaft");
let id = result["feature"].as_u64().unwrap();
let kind = &h.app.session.doc.feature(id).unwrap().kind;
assert!(matches!(kind, solidcraft_engine::doc::FeatureKind::Thread { designation: Some(d), length: Some(l), modeled: true, .. }
if d == "M8x1" && l == "4 mm"));
}
#[test]
fn thread_choices_reset_to_the_face_and_can_freeze_the_same_size() {
let mut h = crate::scenario::Harness::new();
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();
for handle in ["dialog:Size", "dialog:Size:6 mm", "dialog:Pitch", "dialog:Pitch:0.75 mm"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(apply_commands(&h.app, d).unwrap()[0].1["designation"], "M6x0.75");
h.step(&json!({"click":{"handle":"dialog:Automatic size"}})).unwrap();
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == "M8"));
assert!(apply_commands(&h.app, d).unwrap()[0].1.get("designation").is_none());
h.step(&json!({"click":{"handle":"dialog:Automatic size"}})).unwrap();
assert_eq!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["designation"], "M8");
}
#[test]
fn thread_choices_preserve_custom_saved_pairs_and_offer_large_fine_pitches() {
for (diameter, designation) in [(7.3, "M7.3x0.6"), (56.0, "M56")] {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.cylinder", json!({"radius":diameter / 2.0,"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":designation})).unwrap()["feature"].as_u64().unwrap();
h.app.edit_feature(id);
h.frames(2);
assert!(h.widget(&format!("{diameter} mm")).is_some());
assert!(h.widget(if diameter == 56.0 { "5.5 mm" } else { "0.6 mm" }).is_some());
let (_, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
assert_eq!(params["params"]["designation"], designation);
if diameter == 56.0 {
for handle in ["dialog:Pitch", "dialog:Pitch:2 mm"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
let (command, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
assert_eq!(params["params"]["designation"], "M56x2");
h.app.session.execute(&command, &params).unwrap();
let mut reopened = solidcraft_engine::doc::Document::from_json(&h.app.session.doc.to_json()).unwrap();
// This test submits the edit command directly; finish the editor's rollback
// before checking the complete saved design.
reopened.marker = None;
let session = Session::new(reopened);
assert_eq!(session.world_state().threads[0].designation, "M56x2");
}
}
}
#[test]
fn thread_choices_can_start_without_a_face_and_recover_from_invalid_text() {
let mut h = crate::scenario::Harness::new();
h.step(&json!({"start":"solid.thread"})).unwrap();
for handle in ["dialog:Size", "dialog:Size:8 mm", "dialog:thread_size"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"Munknown"})).unwrap();
h.frames(2);
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation == "Munknown"));
for handle in ["dialog:Size", "dialog:Size:8 mm"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation == "M8x1.25"));
assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_err(), "a face is still required");
}
#[test]
fn newer_options_reach_their_commands() {
let mut app = boxed();