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 ## 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 - 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, 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 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.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}", "sketch_text.use_font_file": "Use the font file {path}",
"the parts library is not available": "the parts library is not available", "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.group": "New Feature Folder",
"timeline.move_many": "Move Timeline Items", "timeline.move_many": "Move Timeline Items",
"timeline.move_to_group": "Move Features to Folder", "timeline.move_to_group": "Move Features to Folder",
+4
View File
@@ -783,6 +783,10 @@
"sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック", "sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック",
"sketch_text.use_font_file": "フォント ファイル {path} を使用", "sketch_text.use_font_file": "フォント ファイル {path} を使用",
"the parts library is not available": "パーツライブラリは利用できません", "the parts library is not available": "パーツライブラリは利用できません",
"thread.automatic_size": "サイズを自動選択",
"thread.designation": "ねじの呼び",
"thread.iso_metric": "ISOメートルねじ",
"thread.select_size": "サイズを選択",
"timeline.group": "フィーチャーフォルダを新規作成", "timeline.group": "フィーチャーフォルダを新規作成",
"timeline.move_many": "タイムライン項目を移動", "timeline.move_many": "タイムライン項目を移動",
"timeline.move_to_group": "フィーチャーをフォルダへ移動", "timeline.move_to_group": "フィーチャーをフォルダへ移動",
+4
View File
@@ -783,6 +783,10 @@
"sketch_text.path_hint": "文字所跟随的草图曲线:先单击此处,再选择曲线", "sketch_text.path_hint": "文字所跟随的草图曲线:先单击此处,再选择曲线",
"sketch_text.use_font_file": "使用字体文件 {path}", "sketch_text.use_font_file": "使用字体文件 {path}",
"the parts library is not available": "零件库不可用", "the parts library is not available": "零件库不可用",
"thread.automatic_size": "自动尺寸",
"thread.designation": "螺纹标记",
"thread.iso_metric": "ISO 公制",
"thread.select_size": "选择尺寸",
"timeline.group": "新建特征文件夹", "timeline.group": "新建特征文件夹",
"timeline.move_many": "移动时间线项目", "timeline.move_many": "移动时间线项目",
"timeline.move_to_group": "将特征移至文件夹", "timeline.move_to_group": "将特征移至文件夹",
+13 -1
View File
@@ -209,6 +209,12 @@ const ISO_COARSE: [(f64, f64); 22] = [
(56.0, 5.5), (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). /// "M8" or "M8x1" → (nominal diameter, pitch).
pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> { pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> {
let t = s.trim().to_ascii_uppercase(); let t = s.trim().to_ascii_uppercase();
@@ -221,7 +227,7 @@ pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> {
Some(p) => p, Some(p) => p,
None => ISO_COARSE.iter().find(|(n, _)| (n - d).abs() < 1e-9)?.1, 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 /// 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)) } 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`. /// 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> { fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>) -> Result<ThreadInfo> {
let (body, cyl) = st let (body, cyl) = st
+3 -3
View File
@@ -58,9 +58,9 @@ pub use assembly::{
pub use document::*; pub use document::*;
pub use eval::{ pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, 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, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, object_height, parse_metric_thread,
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame,
through_all_distance, world_state, world_state_reusing, state_in_frame_at, suggested_thread, through_all_distance, world_state, world_state_reusing,
}; };
pub use expr::Expr; pub use expr::Expr;
pub use names::{BodyName, FeatureName, SketchId}; pub use names::{BodyName, FeatureName, SketchId};
+1 -1
View File
@@ -79,7 +79,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.thread", "Thread", thread) CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("thread") .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) CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY") .at("SOLID", "MODIFY")
.icon("shell") .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)]; 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 /// ∫ 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). /// 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 { 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 { Thread {
designation: String, designation: String,
length: 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). /// A rib (one wall along open curves) or a web (a wall per curve).
Rib { Rib {
@@ -529,9 +534,10 @@ impl Dialog {
), ),
"solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]), "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.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]),
"solid.thread" => { "solid.thread" => Dialog::new(
Dialog::new(Kind::Thread { designation: String::new(), length: String::new() }, vec![SelInput::new("Cylindrical face", FACES, false)]) 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)]), "material.assign" => Dialog::new(Kind::Material { index: 1 }, vec![SelInput::new("Bodies", BODIES, true)]),
"inspect.section" => { "inspect.section" => {
Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) 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); inp.items = default_profiles(s);
} }
d.sync_thread(s);
d.advance(); d.advance();
Some(d) 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 { pub fn rename(feature: u64, name: &str) -> Dialog {
Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![]) Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![])
} }
@@ -946,6 +972,7 @@ impl Dialog {
self.advance(); self.advance();
} }
self.error = None; self.error = None;
self.sync_thread(s);
} }
/// Box selection result: replace (or add to) the active input. /// Box selection result: replace (or add to) the active input.
@@ -976,6 +1003,7 @@ impl Dialog {
inp.items.push(x); 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.active = i;
} }
d.sync_thread(s);
} }
/// Each live reference sits with the independent values it drives. /// Each live reference sits with the independent values it drives.
@@ -2110,6 +2139,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return; return;
} }
auto_operation(app, &mut d); auto_operation(app, &mut d);
d.sync_thread(&app.session);
update_measure(app, &mut d); update_measure(app, &mut d);
update_fill_cells(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, // 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); enter |= field(ui, distance);
ui.end_row(); ui.end_row();
} }
Kind::Thread { designation, length } => { Kind::Thread { designation, length, suggested, of } => {
// Fusion's order: Modeled, Full Length, then the size; a length only when not
// full.
row_label(ui, "Modeled"); row_label(ui, "Modeled");
extra_check(ui, &mut d.extra, "modeled"); extra_check(ui, &mut d.extra, "modeled");
ui.end_row(); ui.end_row();
row_label(ui, "Full Length"); row_label(ui, "Full Length");
let mut full = length.trim().is_empty(); 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() }; *length = if full { String::new() } else { "10 mm".into() };
} }
ui.end_row(); ui.end_row();
row_label(ui, "Size"); let (submitted, reset) = crate::dialogs_thread::rows(ui, designation, suggested);
let r = ui.add(egui::TextEdit::singleline(designation).hint_text(tr("fits the face, e.g. M8"))); enter |= submitted;
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); if reset {
ui.end_row(); of.clear();
}
if !full { if !full {
row_label(ui, "Length"); row_label(ui, "Length");
enter |= field(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")?; need(0, "planar faces")?;
("solid.offset_face", json!({"faces": face_points(0), "distance": distance})) ("solid.offset_face", json!({"faces": face_points(0), "distance": distance}))
} }
Kind::Thread { designation, length } => { Kind::Thread { designation, length, suggested, .. } => {
need(0, "a cylindrical face")?; need(0, "a cylindrical face")?;
let mut p = json!({"face": face_points(0).into_iter().next().unwrap_or(Value::Null)}); 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()); p["designation"] = json!(designation.trim());
} }
if !length.trim().is_empty() { 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 { d.kind = Kind::Thread {
designation: designation.clone().unwrap_or_default(), designation: designation.clone().unwrap_or_default(),
length: length.as_deref().map(str::to_string).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() { if let Some(inp) = d.inputs.first_mut() {
inp.items = face_sel(s, face.0).into_iter().collect(); inp.items = face_sel(s, face.0).into_iter().collect();
} }
d.sync_thread(s);
d d
} }
FeatureKind::BoundingSolid { bodies, margin } => { 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_sheet;
pub mod dialogs_surface; pub mod dialogs_surface;
pub mod dialogs_sweep; pub mod dialogs_sweep;
mod dialogs_thread;
pub mod dialogs_view_dxf; pub mod dialogs_view_dxf;
pub mod dim_view; pub mod dim_view;
#[cfg(test)] #[cfg(test)]
+229
View File
@@ -905,6 +905,235 @@ fn variable_fillet_mid_radii_round_trip() {
assert_eq!(p["params"]["mid"], json!([]), "{p}"); 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] #[test]
fn newer_options_reach_their_commands() { fn newer_options_reach_their_commands() {
let mut app = boxed(); let mut app = boxed();