From da65b4fc8c36ac1ac9b13dcade4cd859984b3a46 Mon Sep 17 00:00:00 2001 From: Colin Faulkingham Date: Fri, 16 Oct 2026 11:26:23 -0500 Subject: [PATCH] Thread: suggest cylinder size and expose metric diameter and pitch (#1759) Thanks @cfaulkingham. --- ROADMAP.md | 5 + assets/locales/en.json | 4 + assets/locales/ja.json | 4 + assets/locales/zh.json | 4 + crates/doc/src/eval.rs | 14 +- crates/doc/src/lib.rs | 6 +- crates/engine/src/cmd/features.rs | 2 +- crates/engine/src/thread_tests.rs | 12 ++ crates/ui-egui/src/dialogs.rs | 60 +++++-- crates/ui-egui/src/dialogs_thread.rs | 95 +++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/preselect_tests.rs | 229 ++++++++++++++++++++++++++ 12 files changed, 418 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/dialogs_thread.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1033266f..c3949762 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/assets/locales/en.json b/assets/locales/en.json index b0a05d49..5c5c9f47 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 9e8c692d..f5a41eaa 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "フィーチャーをフォルダへ移動", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 34e1af5a..dbea5f17 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "将特征移至文件夹", diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index aaf39d76..a9c4f566 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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 { + 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) -> Result { let (body, cyl) = st diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ae00b365..285b7d3f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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}; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index bbd35cae..025b2c35 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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") diff --git a/crates/engine/src/thread_tests.rs b/crates/engine/src/thread_tests.rs index 906fcb62..3b725197 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -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 { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d790e712..139e10a4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, + /// The picks used for the suggestion, so an unchanged dialog does no geometry work. + of: Vec, }, /// 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, 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) -> Option { diff --git a/crates/ui-egui/src/dialogs_thread.rs b/crates/ui-egui/src/dialogs_thread.rs new file mode 100644 index 00000000..b9d0cdf1 --- /dev/null +++ b/crates/ui-egui/src/dialogs_thread.rs @@ -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) -> (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 = 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 = 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 = 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) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1d8738b7..a5e76ed5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)] diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index f9433aec..7df2a879 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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();