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.