From 377e12a059257130b07bf2065b9418b9724535ee Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:16:55 +0200 Subject: [PATCH] A modelled thread that would cut nothing is refused (#995) (#1018) * fix: a modelled thread that would cut nothing is refused (#995) An M5 modelled thread in a 5 mm hole cut nothing into the nut: the internal groove runs from the minor diameter out to the major one, so with the hole as wide as the thread it lay entirely in the hole, and the faceted cut only left the wall faceted with slivers ("the thread goes the wrong way"). The fit check takes a 5 mm hole as an M5 hole. - thread_groove measures how deep the groove cuts into the material (hole wall out to the major radius, or shaft surface in to the root); under a quarter of the basic depth 5H/8 the modelled thread is refused with the measured depth, the tap drill (as solid.hole drills it) or shaft size to use, and the size that fits the face, e.g. "this 5.00 mm hole is too wide for a modelled M5 thread: it would cut 0.00 mm of the thread's 0.43 mm depth; drill 4.2 mm for M5, or thread this hole M6". - Cosmetic threads and their fit check are unchanged. * test: pin the cut-nothing limit for coarse and fine pitch; show the hole size with show_mm --------- Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com> Co-authored-by: bherbruck --- crates/doc/src/eval.rs | 18 +++++++++ crates/engine/src/thread_tests.rs | 66 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index a7e61b10..d7b64cba 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -264,6 +264,24 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result 190.0 { return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this pitch); use a cosmetic one", 190.0 * p))); } + // How deep the groove cuts into the material: from the hole wall out to the major diameter, + // or from the shaft's surface in to the root. Next to nothing (a hole as wide as the thread, + // #995) is refused: the faceted cut would leave the face faceted and unthreaded. + let basic = 5.0 * h / 8.0; + let depth = if t.internal { d / 2.0 - radius } else { radius - (d / 2.0 - basic) }; + if !(depth >= basic / 4.0) { + let (m, dia) = (&t.designation, 2.0 * radius); + let cut = format!("it would cut {:.2} mm of the thread's {basic:.2} mm depth", depth.max(0.0)); + let alt = thread_for(dia, t.internal).map(|(n, q)| format_thread(n, q)).filter(|a| a != m); + let shown = show_mm(dia); + return Err(DocError::Invalid(if t.internal { + let alt = alt.map(|a| format!(", or thread this hole {a}")).unwrap_or_default(); + format!("this {shown} mm hole is too wide for a modelled {m} thread: {cut}; drill {} mm for {m}{alt}", show_mm(d - p)) + } else { + let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default(); + format!("this {shown} mm shaft is too thin for a modelled {m} thread: {cut}; make the shaft {} mm for {m}{alt}", show_mm(d)) + })); + } // (radius, half width of the groove) at its two ends: a trapezoid in the axial plane. let (r0, w0, r1, w1) = if t.internal { // From inside the hole wall out to the major diameter, where the bolt's crest sits diff --git a/crates/engine/src/thread_tests.rs b/crates/engine/src/thread_tests.rs index efac56e4..906fcb62 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -68,6 +68,72 @@ fn modelled_internal_threads_m3_to_m20() { } } +/// Issue 995: an M5 modelled thread in a 5 mm hole cut nothing (the groove lay in the hole, from +/// the minor diameter out to the 5 mm major one) and only left the wall faceted. A modelled thread +/// that would cut next to nothing is refused, with the drill to use and the size that fits; +/// a cosmetic one is still only a note. +#[test] +fn a_modelled_thread_that_would_cut_nothing_is_refused() { + let mut s = hex_nut(5.0); + let before = volume(&mut s, "Nut"); + let e = s.execute("solid.thread", &json!({"face": [2.5, 0, 2], "designation": "M5", "modeled": true})).err().map(|e| e.to_string()); + let e = e.unwrap_or_default(); + assert!(e.contains("this 5 mm hole") && e.contains("drill 4.2 mm for M5") && e.contains("thread this hole M6"), "{e}"); + assert!((volume(&mut s, "Nut") - before).abs() < 1e-9); + run(&mut s, "solid.thread", json!({"face": [2.5, 0, 2], "designation": "M5"})); + + // A shaft no thicker than the thread's root (17.29 mm for M20) is refused the same way. + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 8.6, "height": 10, "body_name": "Shaft"})); + let e = s.execute("solid.thread", &json!({"face": [8.6, 0, 5], "designation": "M20", "modeled": true})).err().map(|e| e.to_string()); + let e = e.unwrap_or_default(); + assert!(e.contains("this 17.2 mm shaft") && e.contains("make the shaft 20 mm for M20") && e.contains("thread this shaft M18"), "{e}"); +} + +/// The limit is a quarter of the basic depth 5H/8: a hole up to 4.78 mm for M5 (4.86 mm for +/// M5x0.5) still takes a modelled thread, a wider one is refused with that pitch's drill. +#[test] +fn the_cut_nothing_limit_holds_for_coarse_and_fine_pitch() { + for (m, hole, refused) in [ + ("M5", 4.5, None), + ("M5", 4.75, None), + ("M5", 4.8, Some("drill 4.2 mm for M5, or thread this hole M6")), + ("M5x0.5", 4.5, None), + ("M5x0.5", 4.85, None), + ("M5x0.5", 4.9, Some("drill 4.5 mm for M5x0.5, or thread this hole M6")), + ("M5x0.5", 5.0, Some("drill 4.5 mm for M5x0.5, or thread this hole M6")), + ] { + let mut s = hex_nut(hole); + let before = volume(&mut s, "Nut"); + let made = s.execute("solid.thread", &json!({"face": [hole / 2.0, 0, 2], "designation": m, "modeled": true})); + let cut = before - volume(&mut s, "Nut"); + match (refused, made) { + (None, Ok(_)) => assert!(cut > 0.05, "{m} in {hole} mm: removed {cut}"), + (Some(want), Err(e)) => { + let e = e.to_string(); + assert!(e.contains(&format!("this {hole} mm hole")) && e.contains(want), "{m} in {hole} mm: {e}"); + assert!(cut.abs() < 1e-9, "{m} in {hole} mm: removed {cut}"); + } + (want, got) => panic!("{m} in {hole} mm: want refused {want:?}, got {:?}", got.map(|_| ())), + } + } +} + +/// A 4 mm hex nut (inscribed radius 5) with a plain hole of this diameter through it. +fn hex_nut(hole: f64) -> Session { + let mut s = Session::default(); + for (id, p) in [ + ("sketch.create", json!({"plane": "XY"})), + ("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 6})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance": 4, "body_name": "Nut"})), + ("solid.hole", json!({"position": [0, 0, 4], "diameter": hole})), + ] { + run(&mut s, id, p); + } + s +} + /// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's /// thousands of facets were healed pair by pair, once per feature after it, and the fillet's /// preview then compared each of them with the whole old body.