Thread: suggest cylinder size and expose metric diameter and pitch (#1759)
Thanks @cfaulkingham.
This commit is contained in:
1 parent
741265f214
commit
da65b4fc8c
12 files changed
+418
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "フィーチャーをフォルダへ移動",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user