diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index f11b0d176c..d45806f4ad 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -873,6 +873,31 @@ pub enum FeatureKind { /// Where the thread runs up to from the end it starts at, instead of a length. #[serde(default, skip_serializing_if = "Option::is_none")] up_to: Option, + /// The thread's form; `designation`'s prefix names it when this is the default. + #[serde(default, skip_serializing_if = "crate::thread_forms::is_default")] + form: crate::thread_forms::ThreadForm, + #[serde(default, skip_serializing_if = "crate::thread_forms::is_default")] + hand: crate::thread_forms::ThreadHand, + /// Starts (1 to 8): the lead is starts × pitch (unless `designation` gives the lead). + #[serde(default = "crate::thread_forms::one_start", skip_serializing_if = "crate::thread_forms::is_one_start")] + starts: u32, + /// Runout lengths at the thread's start and end (a groove that fades out). + #[serde(default, skip_serializing_if = "Option::is_none")] + ease_in: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ease_out: Option, + /// The chamfered tip at the thread's free end (ISO 4753). + #[serde(default, skip_serializing_if = "crate::thread_forms::is_default")] + tip: crate::thread_forms::ThreadTip, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[walk(angle = "Chamfer angle")] + chamfer_angle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[walk(length = "Chamfer depth")] + chamfer_depth: Option, + /// Buttress forms: which way the steep flank of the screw's teeth faces. + #[serde(default, skip_serializing_if = "crate::thread_forms::is_default")] + steep_flank: crate::thread_forms::SteepFlank, }, /// Ruled loft through profiles of several sketches, in order. Loft { @@ -1862,6 +1887,19 @@ pub enum ThreadLimit { Plane(PlaneRef), } +/// A thread's runout length at one end: a length, or ISO 3508's standard (2.5 P) or short +/// (1.25 P) runout. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] +#[serde(rename_all = "snake_case")] +pub enum ThreadRunout { + Standard, + Short, + Length { + #[walk(length = "Runout")] + length: Expr, + }, +} + /// What Extend extends up to instead of a distance. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] #[serde(rename_all = "snake_case")] diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 626c92abdc..b91b638300 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -5883,8 +5883,8 @@ pub(crate) mod frame_member; mod surface; #[path = "eval_thread.rs"] mod thread; -use thread::{thread_on, thread_on_impl}; -pub use thread::{ThreadInfo, metric_thread_sizes, parse_metric_thread, suggested_thread}; +use thread::thread_on; +pub use thread::{ThreadInfo, metric_thread_sizes, parse_metric_thread, suggested_thread, suggested_thread_in}; /// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face /// there if it is planar and parallel (its plane), else where a ray from `centre` along the diff --git a/crates/doc/src/eval_thread.rs b/crates/doc/src/eval_thread.rs index c55e6474d0..f7cc184067 100644 --- a/crates/doc/src/eval_thread.rs +++ b/crates/doc/src/eval_thread.rs @@ -1,7 +1,8 @@ -//! Threads: the ISO metric sizes, the cosmetic thread on a cylindrical face, and the modelled +//! Threads: the cosmetic thread on a cylindrical face, and the modelled //! thread's groove cut along a helix. use super::*; +use crate::thread_forms::{SteepFlank, ThreadForm, ThreadHand, ThreadTip}; /// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -10,8 +11,6 @@ pub struct ThreadInfo { pub body: String, pub designation: String, pub major_diameter: f64, - /// Radius of the actual cylindrical face when the cosmetic thread is evaluated. - pub radius: f64, pub pitch: f64, pub internal: bool, pub axis_point: Vec3, @@ -19,76 +18,36 @@ pub struct ThreadInfo { /// Threaded part along the axis from `axis_point`. pub start: f64, pub end: f64, + #[serde(default)] + pub form: ThreadForm, + #[serde(default)] + pub hand: ThreadHand, + #[serde(default = "crate::thread_forms::one_start")] + pub starts: u32, } -/// ISO metric coarse threads: (nominal diameter, pitch). -const ISO_COARSE: [(f64, f64); 22] = [ - (1.6, 0.35), - (2.0, 0.4), - (2.5, 0.45), - (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), - (14.0, 2.0), - (16.0, 2.0), - (18.0, 2.5), - (20.0, 2.5), - (22.0, 2.5), - (24.0, 3.0), - (27.0, 3.0), - (30.0, 3.5), - (36.0, 4.0), - (42.0, 4.5), - (48.0, 5.0), - (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 + &crate::thread_forms::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(); - let body = t.strip_prefix('M')?; - let (d, p) = match body.split_once(['X', '×']) { - Some((d, p)) => (d.trim().parse::().ok()?, Some(p.trim().parse::().ok()?)), - None => (body.trim().parse::().ok()?, None), - }; - let pitch = match p { - Some(p) => p, - None => ISO_COARSE.iter().find(|(n, _)| (n - d).abs() < 1e-9)?.1, - }; - (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 tap drill (major − pitch). -fn thread_for(diameter: f64, internal: bool) -> Option<(f64, f64)> { - ISO_COARSE - .iter() - .map(|&(n, p)| (n, p, if internal { n - p } else { n })) - .min_by(|a, b| (a.2 - diameter).abs().total_cmp(&(b.2 - diameter).abs())) - .filter(|(_, _, want)| (want - diameter).abs() <= 0.15 * diameter) - .map(|(n, p, _)| (n, p)) -} - -pub(crate) fn format_thread(d: f64, p: f64) -> String { - let coarse = ISO_COARSE.iter().any(|(n, q)| (n - d).abs() < 1e-9 && (q - p).abs() < 1e-9); - let num = |x: f64| if (x - x.round()).abs() < 1e-9 { format!("{}", x.round()) } else { format!("{x}") }; - if coarse { format!("M{}", num(d)) } else { format!("M{}x{}", num(d), num(p)) } + ThreadForm::Metric.parse(s, 1).ok().filter(|z| (z.lead - z.pitch).abs() < 1e-12).map(|z| (z.major, z.pitch)) } /// 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, &ThreadAsk::default()).map(|t| t.info.designation) + suggested_thread_in(st, p, ThreadForm::Metric) +} + +/// The standard designation of `form` that fits the cylindrical face at `p` (a shaft its major +/// diameter, a hole its drill), with the same fit check as a thread feature without an explicit +/// size. This query does not change the model. +pub fn suggested_thread_in(st: &ModelState, p: Vec3, form: ThreadForm) -> Result { + thread_on(st, 0, p, &ThreadAsk { form: Some(form), ..Default::default() }).map(|t| t.info.designation) } /// How far a thread runs along its face. @@ -108,7 +67,13 @@ pub(super) enum Extent { /// What a thread feature asks for: its size, how far it runs and from which end. #[derive(Clone, Copy, Debug, Default)] pub(super) struct ThreadAsk<'a> { + /// The form; `None`: the one `designation`'s prefix names, else metric. + pub form: Option, pub designation: Option<&'a str>, + pub hand: ThreadHand, + /// Starts; 0 is taken as 1. + pub starts: u32, + pub steep: SteepFlank, pub extent: Extent, /// A point near the end of the face the thread starts at. pub from: Option, @@ -135,6 +100,7 @@ pub(super) struct ThreadOn { cyl: kernel::CylinderFace, /// How the thread's start and end meet the face (modelled threads only). ends: [End; 2], + steep: SteepFlank, } /// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made @@ -180,33 +146,50 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk) .ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?; let mb = st.bodies.get(bi).ok_or_else(|| DocError::Invalid("threaded body".into()))?; let dia = cyl.radius * 2.0; - let (major, pitch) = match ask.designation { - Some(s) => parse_metric_thread(s).ok_or_else(|| DocError::Invalid(format!("unknown thread `{s}` (ISO metric, like M8 or M8x1)")))?, - None => thread_for(dia, cyl.internal).ok_or_else(|| DocError::Invalid(format!("no metric thread fits a {dia:.2} mm cylinder")))?, + let form = match (ask.form, ask.designation.and_then(ThreadForm::of_designation)) { + (Some(f), _) | (None, Some(f)) => f, + (None, None) => ThreadForm::Metric, }; - let (major, pitch) = match &tapped_size { - Some(size) => (size.major_diameter, size.pitch), - None => match designation { - Some(s) => parse_metric_thread(s).ok_or_else(|| DocError::Invalid(format!("unknown thread `{s}` (ISO metric, like M8 or M8x1)")))?, - None => thread_for(dia, cyl.internal).ok_or_else(|| DocError::Invalid(format!("no metric thread fits a {dia:.2} mm cylinder")))?, - }, + if let Some(why) = form.refused() { + return Err(DocError::Invalid(why.into())); + } + let starts = ask.starts.max(1); + if starts > 8 { + return Err(DocError::Invalid("a thread has 1 to 8 starts".into())); + } + let size = match ask.designation { + Some(s) => form.parse(s, starts).map_err(DocError::Invalid)?, + None => { + let (d, p) = form.fit(dia, cyl.internal).ok_or_else(|| { + DocError::Invalid(if form.sizes().is_empty() { + format!("{} threads have no size table: give the designation ({})", form.name(), form.format(50.8, 25.4 / 8.0, 25.4 / 8.0)) + } else { + format!("no {} thread fits a {dia:.2} mm cylinder", form.name()) + }) + })?; + crate::thread_forms::ThreadSize { form, major: d, pitch: p, lead: p * f64::from(starts) } + } }; - let name = tapped_size.as_ref().map(|s| s.designation.clone()).unwrap_or_else(|| format_thread(major, pitch)); - let fits = if let Some(size) = tapped_size.as_ref().filter(|s| s.family != crate::TappedThreadFamily::Metric) { - // Fit the recommended physical drill, without borrowing an ISO minor-diameter rule. - cyl.internal && (size.tap_drill - dia).abs() < 0.15 * major - } else if cyl.internal { - (major - pitch * 1.0825 - dia).abs() < 0.2 * major || (major - pitch - dia).abs() < 0.15 * major + let (major, pitch) = (size.major, size.pitch); + let designation = form.format(major, pitch, size.lead); + let minor = 2.0 * form.groove(major, pitch, true).crest_r; + let fits = if cyl.internal { + (minor - dia).abs() < 0.2 * major || (form.drill(major, pitch) - dia).abs() < 0.15 * major } else { - (major - dia).abs() < 0.15 * major || (turned && dia > major) + (major - dia).abs() < 0.15 * major }; if !fits { - return Err(DocError::Invalid(format!("an {} thread does not fit a {dia:.2} mm {}", name, if cyl.internal { "hole" } else { "shaft" }))); + return Err(DocError::Invalid(format!("an {designation} thread does not fit a {dia:.2} mm {}", if cyl.internal { "hole" } else { "shaft" }))); } - // The radii the groove cuts: from the minor diameter (ISO 68-1: 5H/8 under the major) to - // the major, and to the face itself where it lies outside that. - let minor_r = major / 2.0 - 5.0 * pitch * 3f64.sqrt() / 16.0; - let band = (cyl.radius.min(minor_r), cyl.radius.max(major / 2.0)); + // The radii the groove cuts: from the external thread's minor diameter (a nut's: its minor) + // to the major (a nut's root), and to the face itself where it lies outside that. + let (r_lo, r_hi) = if cyl.internal { + let g = form.groove(major, pitch, true); + (g.crest_r, g.root_r) + } else { + (form.groove(major, pitch, false).root_r, major / 2.0) + }; + let band = (cyl.radius.min(r_lo), cyl.radius.max(r_hi)); let mut ends = FaceEnds { body: &mb.body, cyl: &cyl, band, pitch, free: [None; 2] }; let (lo, hi) = (cyl.start, cyl.end); let along = |q: Vec3| (q - cyl.axis_point).dot(cyl.axis); @@ -260,17 +243,19 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk) let info = ThreadInfo { feature, body: mb.name.clone(), - designation: format_thread(major, pitch), + designation, major_diameter: major, - radius: cyl.radius, pitch, internal: cyl.internal, axis_point: cyl.axis_point, axis: cyl.axis, start: a, end: b, + form, + hand: ask.hand, + starts, }; - Ok(ThreadOn { info, cyl, ends }) + Ok(ThreadOn { info, cyl, ends, steep: ask.steep }) } /// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end @@ -302,141 +287,198 @@ fn start_side(ends: &mut FaceEnds, from: Option, at: f64) -> Result } } -/// A thread's groove as a screw sweep: its trapezoid `section` in `plane` (an axial plane -/// through `base`), carried `turns` turns about `axis` through `base`, rising `pitch` per turn. +/// A thread's groove as a screw sweep: its `section` in `plane` (an axial plane through `base`), +/// carried `turns` turns about `axis` through `base`, rising `pitch` per turn (negative: a +/// left-handed groove). struct GrooveShape { plane: Plane, - section: [Vec2; 4], + section: Vec, base: Vec3, axis: Vec3, pitch: f64, turns: f64, } -/// The helical space between the teeth of a modelled ISO metric thread (ISO 68-1 basic -/// profile, 60° flanks): cut from the shaft (external) or the hole wall (internal), running from -/// `start` to `end` along the axis. `radius` is the face's own radius, `face` its extent along -/// the axis and `ends` how the thread's two ends meet it. -fn thread_groove_shape(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2]) -> Result { - let (p, d) = (t.pitch, t.major_diameter); - let h = p * 3f64.sqrt() / 2.0; - let slope = (std::f64::consts::PI / 6.0).tan(); +/// The helical spaces between the teeth of a modelled thread of the form's profile: cut from +/// the shaft (external) or the hole wall (internal), running from `start` to `end` along the +/// axis, one groove per start and hand. `radius` is the face's own radius, `face` its extent +/// along the axis and `ends` how the thread's two ends meet it. +fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2], steep: SteepFlank) -> Result> { + let (p, d, form) = (t.pitch, t.major_diameter, t.form); + let lead = p * f64::from(t.starts.max(1)); let len = t.end - t.start; if !(p > 1e-3 && len > 1e-6) { return Err(DocError::Invalid("thread too short".into())); } - if len / p > 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))); + if len / lead > 190.0 { + return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this lead); use a cosmetic one", 190.0 * lead))); } - // 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) }; + let mut g = form.groove(d, p, t.internal); + if steep == SteepFlank::Start { + std::mem::swap(&mut g.flank_lo, &mut g.flank_hi); + } + let alt = |dia: f64| form.fit(dia, t.internal).map(|(n, q)| form.format(n, q, q)).filter(|a| *a != t.designation); + // How deep the groove cuts into the material: from the hole wall out to the internal + // thread's root, or from the shaft's surface in to the external thread's root. Next to + // nothing (a hole as wide as the thread, #995) is refused: the cut would leave the face + // unthreaded. + let basic = g.basic_depth; + let depth = if t.internal { g.root_r - radius } else { radius - g.root_r }; + let drill = show_mm(form.drill(d, p)); 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)) + let alt = alt(dia).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 {drill} mm for {m}{alt}") } else { - let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default(); + let alt = alt(dia).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 - // (the root flat is P/8 wide). - let r_in = radius.min(d / 2.0 - 5.0 * h / 8.0) - 0.1 * p; - (r_in, (p / 8.0 + 2.0 * (d / 2.0 - r_in) * slope) / 2.0, d / 2.0, p / 16.0) - } else { - // From the root (5H/8 under the major diameter, flat P/4 wide) out past the surface. - let r_root = d / 2.0 - 5.0 * h / 8.0; - let r_out = radius.max(d / 2.0) + 0.05 * p; - (r_root, p / 8.0, r_out, (p / 4.0 + 2.0 * (r_out - r_root) * slope) / 2.0) - }; - if w0.max(w1) * 2.0 >= p * 0.98 { + // The section's radial span: from the root out past the surface (external), or from inside + // the hole wall out to the root (internal), so the cut leaves no skin. + let (r0, r1) = if t.internal { (radius.min(g.crest_r) - 0.1 * p, g.root_r) } else { (g.root_r, radius.max(d / 2.0) + 0.05 * p) }; + let wide = if t.internal { r0 } else { r1 }; + if g.width(p, wide) >= p * 0.98 { // The groove would be wider than a pitch: the face is far inside the thread (a hole // narrower than its minor diameter, a shaft thicker than its major one, #2688). let (m, dia) = (&t.designation, 2.0 * radius); - 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 narrow for a modelled {m} thread (its teeth would fill the hole); drill {} mm for {m}{alt}", - show_mm(d - p) - ) + let alt = alt(dia).map(|a| format!(", or thread this hole {a}")).unwrap_or_default(); + format!("this {shown} mm hole is too narrow for a modelled {m} thread (its teeth would fill the hole); drill {drill} mm for {m}{alt}") } else { - let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default(); + let alt = alt(dia).map(|a| format!(", or thread this shaft {a}")).unwrap_or_default(); format!("this {shown} mm shaft is too thick for a modelled {m} thread; make the shaft {} mm for {m}{alt}", show_mm(d)) })); } - let k = t.axis; - let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) }); - let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?; + let (lo0, hi0) = g.extents(p, r0); + let (lo1, hi1) = g.extents(p, r1); + let section = vec![Vec2::new(r0, -lo0), Vec2::new(r1, -lo1), Vec2::new(r1, hi1), Vec2::new(r0, hi0)]; + if !(r0.is_finite() && r1.is_finite() && r0 > 0.0) { + return Err(DocError::Invalid("thread dimensions are out of range".into())); + } // Past a free end of the face the groove runs out into the air, a pitch beyond it. Before - // a closed one it stops with its widest part (half `w` either side of its centre) clear of - // the face's end, so it never cuts into the shoulder there; inside the face it stops square - // at the threaded length's end. - let w = w0.max(w1); + // a closed one it stops with its section clear of the face's end (its centre as far inside + // as the section reaches that way), so it never cuts into the shoulder there; inside the + // face it stops square at the threaded length's end. + let (w_lo, w_hi) = (lo0.max(lo1), hi0.max(hi1)); let s0 = match ends[0] { End::Free => t.start - p, - End::Closed => t.start.max(face.0 + w), + End::Closed => t.start.max(face.0 + w_lo), End::Inside => t.start, }; let s1 = match ends[1] { End::Free => t.end + p, - End::Closed => t.end.min(face.1 - w), + End::Closed => t.end.min(face.1 - w_hi), End::Inside => t.end, }; if !(s1 - s0 > p * 1e-3) { return Err(DocError::Invalid(format!( "the thread is too short to model between the shoulders: its groove needs {} mm clear of each", - show_mm(w) + show_mm(w_lo.max(w_hi)) ))); } + let k = *t.axis; + let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) }); + let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?; let base = t.axis_point + k * s0; - let plane = Plane { origin: base, x: u, y: *k }; - let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)]; - if !(r0.is_finite() && r1.is_finite()) { - return Err(DocError::Invalid("thread dimensions are too large".into())); + let hands: &[f64] = match t.hand { + ThreadHand::Right => &[1.0], + ThreadHand::Left => &[-1.0], + ThreadHand::Both => &[1.0, -1.0], + }; + let n = t.starts.max(1); + let mut out = Vec::new(); + for &h in hands { + for j in 0..n { + // Start j is the first turned a j-th of a turn on: the same helix a j-th of the + // lead along. + let a = std::f64::consts::TAU * f64::from(j) / f64::from(n); + let x = u * a.cos() + k.cross(u) * a.sin(); + // A left-handed groove: turning the same way about the reversed axis while rising + // along it (the screw motion about −k rising −lead per turn). + let (axis, pitch) = if h > 0.0 { (k, lead) } else { (-k, -lead) }; + out.push(GrooveShape { plane: Plane { origin: base, x, y: k }, section: section.clone(), base, axis, pitch, turns: (s1 - s0) / lead }); + } } - Ok(GrooveShape { plane, section, base, axis: *k, pitch: p, turns: (s1 - s0) / p }) + Ok(out) } /// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled /// one cut into its body. pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { - let FeatureKind::Thread { face, designation, length, modeled, from, up_to } = &f.kind else { + let FeatureKind::Thread { + face, + designation, + length, + modeled, + from, + up_to, + form, + hand, + starts, + ease_in, + ease_out, + tip, + chamfer_angle: _, + chamfer_depth: _, + steep_flank, + } = &f.kind + else { return Err(DocError::Invalid("not a thread".into())); }; + if ease_in.is_some() || ease_out.is_some() { + return Err(DocError::Invalid("a thread runout (ease in, ease out) is not built yet".into())); + } + if *tip != ThreadTip::None { + return Err(DocError::Invalid("a chamfered thread tip is not built yet".into())); + } + if *starts > 1 { + return Err(DocError::Invalid("a thread with several starts is not built yet".into())); + } let extent = match (length, up_to) { (Some(e), _) => Extent::Length(val(vals, e, Kind::Length)?), (None, Some(crate::ThreadLimit::Face(q))) => Extent::UpToPoint(q.0), (None, Some(crate::ThreadLimit::Plane(pl))) => Extent::UpTo(doc.resolve_plane_in(vals, pl, 0, Some(st))?), (None, None) => Extent::Full, }; - let ask = ThreadAsk { designation: designation.as_deref(), extent, from: from.map(|q| q.0), modeled: *modeled }; - let ThreadOn { info: t, cyl, ends } = thread_on(st, f.id, face.0, &ask)?; + // A form left at its default is the one the designation names (`Tr20x4`). + let form = (*form != ThreadForm::Metric).then_some(*form); + let ask = ThreadAsk { + form, + designation: designation.as_deref(), + hand: *hand, + starts: *starts, + steep: *steep_flank, + extent, + from: from.map(|q| q.0), + modeled: *modeled, + }; + let ThreadOn { info: t, cyl, ends, steep } = thread_on(st, f.id, face.0, &ask)?; if !*modeled { st.threads.push(t); return Ok(()); } let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; - // The exact groove: SMLib's screw sweep, cut by a B-rep boolean. - let g = thread_groove_shape(&t, cyl.radius, (cyl.start, cyl.end), ends)?; - let section = Region2 { outer: solidcraft_geom::Loop2::polygon(&g.section), holes: Vec::new() }; - let groove = kernel::sweep_helix(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns)?; - let (nb, groove_faces) = (kernel::boolean(&input.body, &groove, BoolOp::Cut)?, groove.face_count()); + // The exact grooves: SMLib's screw sweeps, cut by one B-rep boolean. + let mut grooves = Vec::new(); + for g in thread_grooves(&t, cyl.radius, (cyl.start, cyl.end), ends, steep)? { + let section = Region2 { outer: solidcraft_geom::Loop2::polygon(&g.section), holes: Vec::new() }; + grooves.push(kernel::sweep_helix(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns)?); + } + let tool = match grooves.as_slice() { + [one] => one.clone(), + many => kernel::union_all(&many.iter().collect::>())?, + }; + let (nb, groove_faces) = (kernel::boolean(&mb.body, &tool, BoolOp::Cut)?, tool.face_count()); let nb = nb.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?; // The groove's faces have no role to name them by. let groove = crate::lineage::Lineage::unnamed_faces(groove_faces); - let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove])); + let lineage = mb.traced().lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove])); let nb = Traced { body: nb, lineage }; if let Some(slot) = st.bodies.get_mut(i) { *slot = ModelBody::traced_new(mb.name, nb, mb.feature); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 27383300fb..5b40e55b07 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -67,6 +67,7 @@ pub mod render_surface; pub use project3d::iso_grid; pub mod sheet; pub mod text_params; +pub mod thread_forms; pub use assembly::{ IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, is_placement, @@ -79,7 +80,7 @@ pub use eval::{ blend_target, curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, map_wire, 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, - tangent_face_chain, through_all_distance, world_state, world_state_reusing, + suggested_thread_in, tangent_face_chain, through_all_distance, world_state, world_state_reusing, }; pub use expr::Expr; pub use names::{BodyName, FeatureId, FeatureName, SketchId, copy_name}; diff --git a/crates/doc/src/thread_forms.rs b/crates/doc/src/thread_forms.rs new file mode 100644 index 0000000000..2ca79cbc87 --- /dev/null +++ b/crates/doc/src/thread_forms.rs @@ -0,0 +1,606 @@ +//! Thread forms from the standards: each form's basic or design profile, its standard sizes and +//! its designations. docs/threads.md gives the values and where they come from. +//! +//! A groove is the space between the teeth, in an axial half-plane: a polygon in (r, z), r from +//! the axis, z along it, the groove's walls straight flanks (crest and root radii are not +//! modelled, so every groove is a polygon the screw sweep carries exactly). + +use serde::{Deserialize, Serialize}; + +const INCH: f64 = 25.4; + +/// A thread form. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadForm { + /// ISO metric, 60° (ISO 68-1 basic profile, ISO 261/262 sizes): `M10`, `M10x1`. + #[default] + Metric, + /// ISO metric trapezoidal, 30° (ISO 2904 design profile, ISO 2902 sizes): `Tr20x4`. + Trapezoidal, + /// General purpose Acme, 29° (ASME B1.5): `1/2-10 ACME`. + Acme, + /// Metric buttress (saw-tooth), 3°/30° (DIN 513-1): `S20x4`. + Buttress, + /// Buttress inch, 7°/45° (ASME B1.9 basic profile): `2.5-8 BUTT`. + ButtressAsme, + /// The square thread: no current standard defines it, so it is refused. + Square, +} + +/// Which way a thread turns. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadHand { + #[default] + Right, + Left, + /// A right- and a left-handed groove crossing on one face (a reversing screw). + Both, +} + +/// Which way the steep (load) flank of a buttress screw's teeth faces along the thread: towards +/// the thread's end (the way its axis points) or its start. A nut cut with the same setting mates. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SteepFlank { + Start, + #[default] + End, +} + +/// The thread's free end: as cut, or chamfered (ISO 4753). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadTip { + #[default] + None, + Chamfer, +} + +/// For `skip_serializing_if`: a field at its default is left out of the saved document. +pub fn is_default(t: &T) -> bool { + *t == T::default() +} + +pub fn one_start() -> u32 { + 1 +} + +pub fn is_one_start(n: &u32) -> bool { + *n == 1 +} + +/// A thread's size: nominal (major) diameter, pitch and lead, all in mm. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThreadSize { + pub form: ThreadForm, + pub major: f64, + pub pitch: f64, + /// starts × pitch. + pub lead: f64, +} + +/// ISO 261/262 coarse pitches: (nominal diameter, pitch). +pub(crate) const ISO_COARSE: [(f64, f64); 22] = [ + (1.6, 0.35), + (2.0, 0.4), + (2.5, 0.45), + (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), + (14.0, 2.0), + (16.0, 2.0), + (18.0, 2.5), + (20.0, 2.5), + (22.0, 2.5), + (24.0, 3.0), + (27.0, 3.0), + (30.0, 3.5), + (36.0, 4.0), + (42.0, 4.5), + (48.0, 5.0), + (56.0, 5.5), +]; + +/// The ISO 2902 diameter-pitch series' middle pitch per diameter, which ISO 2904 (trapezoidal) +/// and DIN 513-2 (buttress, from 10 mm) share: (nominal diameter, pitch). +const ISO_2902_MEDIUM: [(f64, f64); 41] = [ + (8.0, 1.5), + (9.0, 2.0), + (10.0, 2.0), + (11.0, 2.0), + (12.0, 3.0), + (14.0, 3.0), + (16.0, 4.0), + (18.0, 4.0), + (20.0, 4.0), + (22.0, 5.0), + (24.0, 5.0), + (26.0, 5.0), + (28.0, 5.0), + (30.0, 6.0), + (32.0, 6.0), + (34.0, 6.0), + (36.0, 6.0), + (38.0, 7.0), + (40.0, 7.0), + (42.0, 7.0), + (44.0, 7.0), + (46.0, 8.0), + (48.0, 8.0), + (50.0, 8.0), + (52.0, 8.0), + (55.0, 9.0), + (60.0, 9.0), + (65.0, 10.0), + (70.0, 10.0), + (75.0, 10.0), + (80.0, 10.0), + (85.0, 12.0), + (90.0, 12.0), + (95.0, 12.0), + (100.0, 12.0), + (105.0, 12.0), + (110.0, 12.0), + (115.0, 14.0), + (120.0, 14.0), + (125.0, 14.0), + (130.0, 14.0), +]; + +/// ASME B1.5 general purpose Acme: (nominal diameter, threads per inch), in inches. +const ACME_SIZES: [(&str, f64, f64); 23] = [ + ("1/4", 0.25, 16.0), + ("5/16", 0.3125, 14.0), + ("3/8", 0.375, 12.0), + ("7/16", 0.4375, 12.0), + ("1/2", 0.5, 10.0), + ("5/8", 0.625, 8.0), + ("3/4", 0.75, 6.0), + ("7/8", 0.875, 6.0), + ("1", 1.0, 5.0), + ("1 1/8", 1.125, 5.0), + ("1 1/4", 1.25, 5.0), + ("1 3/8", 1.375, 4.0), + ("1 1/2", 1.5, 4.0), + ("1 3/4", 1.75, 4.0), + ("2", 2.0, 4.0), + ("2 1/4", 2.25, 3.0), + ("2 1/2", 2.5, 3.0), + ("2 3/4", 2.75, 3.0), + ("3", 3.0, 2.0), + ("3 1/2", 3.5, 2.0), + ("4", 4.0, 2.0), + ("4 1/2", 4.5, 2.0), + ("5", 5.0, 2.0), +]; + +/// The radii and widths of a form's groove, external or internal, for one size. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GrooveProfile { + /// The flank angles from the radial direction of the groove's wall towards −z and towards +z. + pub flank_lo: f64, + pub flank_hi: f64, + /// Where the groove's teeth have their crest: the major radius (external) or the minor one + /// (internal), and the tooth's width there. + pub crest_r: f64, + pub crest_flat: f64, + /// The groove's root: the minor radius (external) or the major one (internal). + pub root_r: f64, + /// The basic depth of engagement, for the "cuts next to nothing" check. + pub basic_depth: f64, + /// A nut's groove: its teeth's crests towards the axis. + pub internal: bool, +} + +impl GrooveProfile { + /// The groove's axial width at radius `r`: the pitch less the tooth there. + pub fn width(&self, pitch: f64, r: f64) -> f64 { + // How far `r` is from the crest towards the root (negative past the crest, in the air + // the groove reaches into). + let into = if self.internal { r - self.crest_r } else { self.crest_r - r }; + pitch - self.crest_flat - into * (self.flank_lo.tan() + self.flank_hi.tan()) + } + /// The groove's (−z, +z) extents at radius `r`, its root flat centred on z = 0. + pub fn extents(&self, pitch: f64, r: f64) -> (f64, f64) { + let root = self.width(pitch, self.root_r) / 2.0; + let d = (r - self.root_r).abs(); + (root + d * self.flank_lo.tan(), root + d * self.flank_hi.tan()) + } + /// One turn of the groove over radii `a..b` (between root and crest): 2π∫ r w(r) dr. + pub fn turn_volume(&self, pitch: f64, a: f64, b: f64) -> f64 { + // w(r) = c0 + c1 r, linear in r. + let (w0, w1) = (self.width(pitch, a), self.width(pitch, b)); + if (b - a).abs() < 1e-15 { + return 0.0; + } + let c1 = (w1 - w0) / (b - a); + let c0 = w0 - c1 * a; + let f = |r: f64| c0 * r * r / 2.0 + c1 * r * r * r / 3.0; + 2.0 * std::f64::consts::PI * (f(b) - f(a)) + } +} + +impl ThreadForm { + pub const ALL: [ThreadForm; 6] = + [ThreadForm::Metric, ThreadForm::Trapezoidal, ThreadForm::Acme, ThreadForm::Buttress, ThreadForm::ButtressAsme, ThreadForm::Square]; + + pub fn name(self) -> &'static str { + match self { + ThreadForm::Metric => "metric", + ThreadForm::Trapezoidal => "trapezoidal", + ThreadForm::Acme => "acme", + ThreadForm::Buttress => "buttress", + ThreadForm::ButtressAsme => "buttress_asme", + ThreadForm::Square => "square", + } + } + + pub fn from_name(s: &str) -> Option { + ThreadForm::ALL.into_iter().find(|f| f.name().eq_ignore_ascii_case(s.trim())) + } + + /// The standard a form's profile and sizes come from. + pub fn standard(self) -> &'static str { + match self { + ThreadForm::Metric => "ISO 68-1, ISO 261", + ThreadForm::Trapezoidal => "ISO 2904, ISO 2902", + ThreadForm::Acme => "ASME B1.5", + ThreadForm::Buttress => "DIN 513-1, DIN 513-2", + ThreadForm::ButtressAsme => "ASME B1.9", + ThreadForm::Square => "none", + } + } + + /// Why a form cannot be cut, if it cannot. + pub fn refused(self) -> Option<&'static str> { + (self == ThreadForm::Square).then_some( + "no current standard defines a square thread (ISO, ASME and DIN have none; Machinery's Handbook gives only historical proportions): use trapezoidal (ISO 2904) or acme (ASME B1.5)", + ) + } + + /// The form a designation's prefix names: `M` metric, `Tr` trapezoidal, `S` buttress, an inch + /// size with `ACME` or `BUTT`. + pub fn of_designation(s: &str) -> Option { + let t = s.trim().to_ascii_uppercase(); + if t.contains("ACME") { + Some(ThreadForm::Acme) + } else if t.contains("BUTT") { + Some(ThreadForm::ButtressAsme) + } else if t.starts_with("TR") { + Some(ThreadForm::Trapezoidal) + } else if t.starts_with('S') { + Some(ThreadForm::Buttress) + } else if t.starts_with('M') { + Some(ThreadForm::Metric) + } else { + None + } + } + + /// The standard sizes, (nominal diameter, pitch) in mm; none for ASME buttress (its pitch is + /// always given) and the square thread. + pub fn sizes(self) -> Vec<(f64, f64)> { + match self { + ThreadForm::Metric => ISO_COARSE.to_vec(), + ThreadForm::Trapezoidal => ISO_2902_MEDIUM.to_vec(), + ThreadForm::Buttress => ISO_2902_MEDIUM.iter().copied().filter(|(d, _)| *d >= 10.0).collect(), + ThreadForm::Acme => ACME_SIZES.iter().map(|(_, d, n)| (d * INCH, INCH / n)).collect(), + ThreadForm::ButtressAsme | ThreadForm::Square => Vec::new(), + } + } + + /// The standard designations of [`sizes`](Self::sizes), in order. + pub fn designations(self) -> Vec { + self.sizes().into_iter().map(|(d, p)| self.format(d, p, p)).collect() + } + + /// The size a designation names (`starts` starts unless the designation gives its lead). + pub fn parse(self, s: &str, starts: u32) -> Result { + if let Some(why) = self.refused() { + return Err(why.into()); + } + let bad = || format!("unknown {} thread `{s}` ({})", self.name(), self.example()); + if let Some(f) = ThreadForm::of_designation(s) + && f != self + { + return Err(format!("`{s}` is a {} designation, not {}", f.name(), self.name())); + } + let t = s.trim().to_ascii_uppercase().replace('×', "X").replace(' ', ""); + let n = f64::from(starts.max(1)); + let (major, pitch, lead) = match self { + ThreadForm::Metric => { + let body = t.strip_prefix('M').ok_or_else(bad)?; + // M16xPh3P1.5 (ISO 965-1: lead 3, pitch 1.5), M10x1.5, M10. + match body.split_once('X') { + Some((d, rest)) => { + let d = num(d).ok_or_else(bad)?; + match rest.strip_prefix("PH").and_then(|r| r.split_once('P')) { + Some((l, p)) => (d, num(p).ok_or_else(bad)?, num(l).ok_or_else(bad)?), + None => { + let p = num(rest).ok_or_else(bad)?; + (d, p, p * n) + } + } + } + None => { + let d = num(body).ok_or_else(bad)?; + let p = self.coarse(d).ok_or_else(|| format!("no coarse pitch for M{} (give it: M{}x1)", show(d), show(d)))?; + (d, p, p * n) + } + } + } + ThreadForm::Trapezoidal | ThreadForm::Buttress => { + let body = t.strip_prefix(if self == ThreadForm::Trapezoidal { "TR" } else { "S" }).ok_or_else(bad)?; + // Tr40x7, Tr40x14(P7) (ISO 2903: lead 14, pitch 7), Tr40 (the standard pitch). + match body.split_once('X') { + Some((d, rest)) => { + let d = num(d).ok_or_else(bad)?; + match rest.split_once("(P") { + Some((l, p)) => (d, num(p.strip_suffix(')').ok_or_else(bad)?).ok_or_else(bad)?, num(l).ok_or_else(bad)?), + None => { + let p = num(rest).ok_or_else(bad)?; + (d, p, p * n) + } + } + } + None => { + let d = num(body).ok_or_else(bad)?; + let p = self.coarse(d).ok_or_else(|| format!("no standard pitch for a {} mm {} thread: give it", show(d), self.name()))?; + (d, p, p * n) + } + } + } + ThreadForm::Acme | ThreadForm::ButtressAsme => { + // 1/2-10 ACME, 1 1/4-5, 2.5-8 BUTT (a class such as -2G or -2A is a tolerance, ignored). + let body = t.trim_end_matches("ACME").trim_end_matches("PUSH-BUTT").trim_end_matches("PULL-BUTT").trim_end_matches("BUTT"); + let body = body.trim_end_matches('-'); + let mut parts = body.split('-'); + let d = parts.next().and_then(inches).ok_or_else(bad)?; + let tpi = parts.next().and_then(num).ok_or_else(bad)?; + if let Some(rest) = parts.next() + && !(rest.len() == 2 && rest.ends_with(['G', 'C', 'A', 'B'])) + { + return Err(bad()); + } + let p = INCH / tpi; + (d * INCH, p, p * n) + } + ThreadForm::Square => return Err(bad()), + }; + if !(major.is_finite() && pitch.is_finite() && lead.is_finite() && major > 0.0 && pitch > 0.0 && pitch < major) { + return Err(bad()); + } + let k = lead / pitch; + if !(k >= 1.0 - 1e-9 && (k - k.round()).abs() < 1e-6 && k.round() <= 8.0) { + return Err(format!("the lead of `{s}` is not 1 to 8 times its pitch")); + } + Ok(ThreadSize { form: self, major, pitch, lead }) + } + + fn example(self) -> &'static str { + match self { + ThreadForm::Metric => "like M8 or M8x1", + ThreadForm::Trapezoidal => "like Tr20x4 or Tr40x14(P7)", + ThreadForm::Acme => "like 1/2-10 ACME", + ThreadForm::Buttress => "like S20x4 or S40x14(P7)", + ThreadForm::ButtressAsme => "like 2.5-8 BUTT", + ThreadForm::Square => "no standard", + } + } + + /// The standard pitch of a diameter in the form's table. + fn coarse(self, d: f64) -> Option { + self.sizes().into_iter().find(|(n, _)| (n - d).abs() < 1e-9).map(|(_, p)| p) + } + + /// A size's designation, in the form its standard writes it. + pub fn format(self, d: f64, p: f64, lead: f64) -> String { + let multi = (lead - p).abs() > 1e-9; + match self { + ThreadForm::Metric => { + if multi { + format!("M{}xPh{}P{}", show(d), show(lead), show(p)) + } else if self.coarse(d).is_some_and(|q| (q - p).abs() < 1e-9) { + format!("M{}", show(d)) + } else { + format!("M{}x{}", show(d), show(p)) + } + } + ThreadForm::Trapezoidal | ThreadForm::Buttress => { + let pre = if self == ThreadForm::Trapezoidal { "Tr" } else { "S" }; + if multi { format!("{pre}{}x{}(P{})", show(d), show(lead), show(p)) } else { format!("{pre}{}x{}", show(d), show(p)) } + } + ThreadForm::Acme | ThreadForm::ButtressAsme => { + // Acme sizes by their fractions (ASME B1.5's table), buttress in decimal inches. + let di = d / INCH; + let named = ACME_SIZES.iter().find(|(_, x, _)| self == ThreadForm::Acme && (x - di).abs() < 1e-9); + let name = named.map(|(n, _, _)| n.to_string()).unwrap_or_else(|| show(di)); + let tpi = show(INCH / p); + format!("{name}-{tpi} {}", if self == ThreadForm::Acme { "ACME" } else { "BUTT" }) + } + ThreadForm::Square => format!("{}x{}", show(d), show(p)), + } + } + + /// The minor diameter a hole for this size is drilled to: the tap drill (major − pitch) for + /// metric, the internal thread's minor diameter for the others. + pub fn drill(self, d: f64, p: f64) -> f64 { + match self { + ThreadForm::Metric => d - p, + _ => 2.0 * self.groove(d, p, true).crest_r, + } + } + + /// The standard size a cylinder of this diameter takes: a shaft is the major diameter, a hole + /// the drill. None when no size is within 15% (or the form has no size table). + pub fn fit(self, diameter: f64, internal: bool) -> Option<(f64, f64)> { + self.sizes() + .into_iter() + .map(|(n, p)| (n, p, if internal { self.drill(n, p) } else { n })) + .min_by(|a, b| (a.2 - diameter).abs().total_cmp(&(b.2 - diameter).abs())) + .filter(|(_, _, want)| (want - diameter).abs() <= 0.15 * diameter) + .map(|(n, p, _)| (n, p)) + } + + /// The groove of size (d, P): external (between a screw's teeth) or internal (a nut's). + /// `flank_lo`/`flank_hi` are for a buttress with its steep flank towards the end (+z); the + /// caller swaps them for [`SteepFlank::Start`]. + pub fn groove(self, d: f64, p: f64, internal: bool) -> GrooveProfile { + let deg = |a: f64| a.to_radians(); + let half = |a: f64| (deg(a), deg(a)); + // (flanks, tooth crest flat, basic depth, external minor radius, internal minor radius, + // internal major radius) + let (fl, flat, depth, r3, r1, r4) = match self { + ThreadForm::Metric | ThreadForm::Square => { + // ISO 68-1: H = P sqrt3/2, basic depth 5H/8, flats P/8 at the major and P/4 at + // the minor diameter. + let h = p * 3f64.sqrt() / 2.0; + let r_minor = d / 2.0 - 5.0 * h / 8.0; + let flat = if internal { p / 4.0 } else { p / 8.0 }; + (half(30.0), flat, 5.0 * h / 8.0, r_minor, r_minor, d / 2.0) + } + ThreadForm::Trapezoidal => { + // ISO 2904: 30°, H1 = P/2, flats P/2 - P/2 tan 15° (0.366 P) at both crests, + // clearance ac on the external minor and internal major diameters. + let ac = if p <= 1.5 { + 0.15 + } else if p <= 5.0 { + 0.25 + } else if p <= 12.0 { + 0.5 + } else { + 1.0 + }; + let flat = p / 2.0 - p / 2.0 * deg(15.0).tan(); + (half(15.0), flat, p / 2.0, d / 2.0 - p / 2.0 - ac, d / 2.0 - p / 2.0, d / 2.0 + ac) + } + ThreadForm::Acme => { + // ASME B1.5 general purpose: 29°, h = P/2, flat 0.3707 P, the external minor + // 0.020" under the basic minor (0.010" finer than 10 per inch), the internal + // major as far over the basic major. + let a = if p >= INCH / 10.0 - 1e-9 { 0.020 * INCH } else { 0.010 * INCH }; + let flat = p / 2.0 - p / 2.0 * deg(14.5).tan(); + (half(14.5), flat, p / 2.0, d / 2.0 - p / 2.0 - a / 2.0, d / 2.0 - p / 2.0, d / 2.0 + a / 2.0) + } + ThreadForm::Buttress => { + // DIN 513-1: 3° load flank, 30° clearance flank, H1 = 0.75 P, flats w = 0.26384 P, + // the external minor h3 = 0.86777 P deep (clearance ac = 0.11777 P), the internal + // thread from D1 = d - 1.5 P to D = d. + let (t3, t30) = (deg(3.0).tan(), deg(30.0).tan()); + let flat = (p - 0.75 * p * (t3 + t30)) / 2.0; + ((deg(3.0), deg(30.0)), flat, 0.75 * p, d / 2.0 - 0.86777 * p, d / 2.0 - 0.75 * p, d / 2.0) + } + ThreadForm::ButtressAsme => { + // ASME B1.9 basic profile: 7° load flank, 45° clearance flank, h = 0.6 P, flats + // (P - 0.6 P (tan 7° + tan 45°))/2 = 0.16316 P, no allowances. + let flat = (p - 0.6 * p * (deg(7.0).tan() + deg(45.0).tan())) / 2.0; + ((deg(7.0), deg(45.0)), flat, 0.6 * p, d / 2.0 - 0.6 * p, d / 2.0 - 0.6 * p, d / 2.0) + } + }; + // The steep flank faces +z on the screw's teeth: the external groove's −z wall, the + // internal groove's +z wall (the nut's teeth face the other way). + let (lo, hi) = if internal { (fl.1, fl.0) } else { fl }; + if internal { + GrooveProfile { flank_lo: lo, flank_hi: hi, crest_r: r1, crest_flat: flat, root_r: r4, basic_depth: depth, internal } + } else { + GrooveProfile { flank_lo: lo, flank_hi: hi, crest_r: d / 2.0, crest_flat: flat, root_r: r3, basic_depth: depth, internal } + } + } +} + +/// A number; fractions allowed (`1/2`). +fn num(s: &str) -> Option { + let s = s.trim(); + let v = match s.split_once('/') { + Some((a, b)) => a.parse::().ok()? / b.parse::().ok()?, + None => s.parse::().ok()?, + }; + v.is_finite().then_some(v) +} + +/// An inch size: `1`, `0.75`, `3/4`, `1 1/4` (spaces already gone: `11/4` is read as 1 1/4 only +/// from the table's names). +fn inches(s: &str) -> Option { + if let Some((_, d, _)) = ACME_SIZES.iter().find(|(n, _, _)| n.replace(' ', "") == s) { + return Some(*d); + } + num(s) +} + +fn show(v: f64) -> String { + let s = format!("{v:.6}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn designations_read_and_write_back() { + for (form, s, d, p, l) in [ + (ThreadForm::Metric, "M10", 10.0, 1.5, 1.5), + (ThreadForm::Metric, "M10x1", 10.0, 1.0, 1.0), + (ThreadForm::Metric, "M16xPh3P1.5", 16.0, 1.5, 3.0), + (ThreadForm::Trapezoidal, "Tr20x4", 20.0, 4.0, 4.0), + (ThreadForm::Trapezoidal, "Tr40x14(P7)", 40.0, 7.0, 14.0), + (ThreadForm::Buttress, "S40x7", 40.0, 7.0, 7.0), + (ThreadForm::Acme, "1/2-10 ACME", 12.7, 2.54, 2.54), + (ThreadForm::Acme, "1 1/4-5 ACME", 31.75, 5.08, 5.08), + (ThreadForm::ButtressAsme, "2.5-8 BUTT", 63.5, 3.175, 3.175), + ] { + let z = form.parse(s, 1).unwrap(); + assert!((z.major - d).abs() < 1e-9 && (z.pitch - p).abs() < 1e-9 && (z.lead - l).abs() < 1e-9, "{s}: {z:?}"); + assert_eq!(form.format(z.major, z.pitch, z.lead), s); + assert_eq!(ThreadForm::of_designation(s), Some(form), "{s}"); + } + assert!(ThreadForm::Trapezoidal.parse("M10", 1).is_err()); + assert!(ThreadForm::Square.parse("20x4", 1).is_err()); + assert_eq!(ThreadForm::Trapezoidal.parse("Tr20x4", 2).unwrap().lead, 8.0); + } + + /// The design diameters against the standards' tables: ISO 2904 (Tr10x2: d3 7.5, D4 10.5, D1 8; + /// Tr40x7: 32, 41, 33; Tr100x20: 78, 102, 80), DIN 513 (S10x2: d3 6.529, D1 7; S40x7: 27.851, + /// 29.5), ISO 68-1 (M10: d1 = 8.376). + #[test] + fn design_diameters_match_the_tables() { + let ext = |f: ThreadForm, d: f64, p: f64| 2.0 * f.groove(d, p, false).root_r; + let int = |f: ThreadForm, d: f64, p: f64| (2.0 * f.groove(d, p, true).crest_r, 2.0 * f.groove(d, p, true).root_r); + let tr = ThreadForm::Trapezoidal; + for (d, p, d3, d4, d1) in [(10.0, 2.0, 7.5, 10.5, 8.0), (40.0, 7.0, 32.0, 41.0, 33.0), (100.0, 20.0, 78.0, 102.0, 80.0)] { + assert!((ext(tr, d, p) - d3).abs() < 1e-9 && (int(tr, d, p).0 - d1).abs() < 1e-9 && (int(tr, d, p).1 - d4).abs() < 1e-9); + } + let s = ThreadForm::Buttress; + for (d, p, d3, d1) in [(10.0, 2.0, 6.529, 7.0), (40.0, 7.0, 27.851, 29.5)] { + assert!((ext(s, d, p) - d3).abs() < 5e-4 && (int(s, d, p).0 - d1).abs() < 1e-9, "S{d}x{p}"); + } + assert!((ext(ThreadForm::Metric, 10.0, 1.5) - 8.376).abs() < 5e-4); + // DIN 513 flat w = 0.26384 P. + assert!((s.groove(10.0, 2.0, false).crest_flat - 0.26384 * 2.0).abs() < 1e-5); + // ASME B1.9 flat 0.16316 P, B1.5 flat 0.3707 P. + assert!((ThreadForm::ButtressAsme.groove(50.0, 4.0, false).crest_flat - 0.16316 * 4.0).abs() < 1e-4); + assert!((ThreadForm::Acme.groove(25.4, 5.08, false).crest_flat - 0.3707 * 5.08).abs() < 1e-3); + } + + /// The basic profiles mesh: at the pitch line (half the basic depth in from the crest) groove + /// and tooth are each half a pitch (symmetric forms), and an external tooth fits the internal + /// groove of the same size. + #[test] + fn grooves_are_half_a_pitch_at_the_pitch_line() { + for f in [ThreadForm::Metric, ThreadForm::Trapezoidal, ThreadForm::Acme] { + let (d, p) = (20.0, 2.5); + let g = f.groove(d, p, false); + let r = d / 2.0 - match f { + ThreadForm::Metric => 3.0 * p * 3f64.sqrt() / 16.0, + _ => p / 4.0, + }; + assert!((g.width(p, r) - p / 2.0).abs() < 1e-9, "{f:?}"); + } + } +} diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 5e0fd476d6..455f767304 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -63,6 +63,10 @@ impl Walk for crate::document::ExtendMode {} impl Walk for crate::document::RuledMode {} impl Walk for crate::document::RuledCorners {} impl Walk for crate::document::ExtrudeConvention {} +impl Walk for crate::thread_forms::ThreadForm {} +impl Walk for crate::thread_forms::ThreadHand {} +impl Walk for crate::thread_forms::ThreadTip {} +impl Walk for crate::thread_forms::SteepFlank {} /// A reference walks itself: a sketch, a feature or a body. macro_rules! reference { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 92c6f75fe6..e8d4b021f8 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -93,7 +93,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); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"), + .params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table) (default: the standard size that fits the face, a shaft its major diameter, a hole its drill); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch; not built yet); ease_in?, ease_out?: expr|standard|short (runout, not built yet); tip?: none|chamfer (with chamfer_angle?, chamfer_depth?; not built yet); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"), CommandSpec::new("solid.shell", "Shell", shell) .at("SOLID", "MODIFY") .icon("shell") @@ -2896,12 +2896,68 @@ fn thread(s: &mut Session, p: &Value) -> Result { .get("face") .and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3))) .ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on a cylindrical face"))?; + use solidcraft_doc::thread_forms::{SteepFlank, ThreadForm, ThreadHand, ThreadTip}; let designation = str_(p, "designation").map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); - if let Some(t) = &designation - && solidcraft_doc::parse_metric_thread(t).is_none() - { - return Err(bad(cmd, unknown_metric_thread(t, "M8 or M8x1"))); + let form = match str_(p, "form") { + Some(f) => ThreadForm::from_name(f) + .ok_or_else(|| bad(cmd, format!("unknown thread form `{f}` (metric, trapezoidal, acme, buttress, buttress_asme or square)")))?, + None => designation.as_deref().and_then(ThreadForm::of_designation).unwrap_or_default(), + }; + if let Some(why) = form.refused() { + return Err(bad(cmd, why)); } + let starts = match p.get("starts") { + None | Some(Value::Null) => 1, + Some(v) => v.as_u64().filter(|n| (1..=8).contains(n)).ok_or_else(|| bad(cmd, "`starts` must be a whole number from 1 to 8"))? as u32, + }; + if let Some(t) = &designation { + form.parse(t, starts).map_err(|e| bad(cmd, e))?; + } + let hand = match str_(p, "hand") { + None | Some("right") => ThreadHand::Right, + Some("left") => ThreadHand::Left, + Some("both") => ThreadHand::Both, + Some(h) => return Err(bad(cmd, format!("unknown hand `{h}` (right, left or both)"))), + }; + let steep_flank = match str_(p, "steep_flank") { + None | Some("end") => SteepFlank::End, + Some("start") => SteepFlank::Start, + Some(h) => return Err(bad(cmd, format!("unknown steep_flank `{h}` (start or end)"))), + }; + if p.get("steep_flank").is_some() && !matches!(form, ThreadForm::Buttress | ThreadForm::ButtressAsme) { + return Err(bad(cmd, "`steep_flank` is for the buttress forms")); + } + let tip = match str_(p, "tip") { + None | Some("none") => ThreadTip::None, + Some("chamfer") => ThreadTip::Chamfer, + Some(t) => return Err(bad(cmd, format!("unknown tip `{t}` (none or chamfer)"))), + }; + let angled = |key: &str, kind: Kind| -> Result> { + let e = expr(p, key); + if let Some(e) = &e { + check_expr(s, e, kind, cmd, key)?; + } + Ok(e) + }; + let chamfer_angle = angled("chamfer_angle", Kind::Angle)?; + let chamfer_depth = angled("chamfer_depth", Kind::Length)?; + if tip == ThreadTip::None && (chamfer_angle.is_some() || chamfer_depth.is_some()) { + return Err(bad(cmd, "`chamfer_angle` and `chamfer_depth` go with `tip: chamfer`")); + } + let ease = |key: &str| -> Result> { + Ok(match p.get(key) { + None | Some(Value::Null) => None, + Some(Value::String(t)) if t == "standard" => Some(solidcraft_doc::ThreadRunout::Standard), + Some(Value::String(t)) if t == "short" => Some(solidcraft_doc::ThreadRunout::Short), + Some(Value::Number(n)) if n.as_f64() == Some(0.0) => None, + Some(_) => { + let e = expr(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a length, \"standard\" or \"short\"")))?; + check_expr(s, &e, Kind::Length, cmd, key)?; + Some(solidcraft_doc::ThreadRunout::Length { length: e }) + } + }) + }; + let (ease_in, ease_out) = (ease("ease_in")?, ease("ease_out")?); let length = expr(p, "length"); if let Some(l) = &length { check_expr(s, l, Kind::Length, cmd, "length")?; @@ -2926,13 +2982,33 @@ fn thread(s: &mut Session, p: &Value) -> Result { Some(v) => Some(Position(vec3(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y, z] near the end the thread starts at"))?)), }; let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false); + // The coarse metric range bounds only a metric thread with no designation given. if designation.is_none() + && form == ThreadForm::Metric && let Some(msg) = beyond_coarse_range(&s.model.state(), face) { return Err(bad(cmd, msg)); } let handedness = thread_handedness(p, cmd)?; - let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled, handedness, from, up_to })?; + let kind = FeatureKind::Thread { + face: FacePick(face), + designation, + length, + modeled, + handedness, + from, + up_to, + form, + hand, + starts, + ease_in, + ease_out, + tip, + chamfer_angle, + chamfer_depth, + steep_flank, + }; + let v = add_feature(s, p, kind)?; let st = s.model.state(); let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned()); Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t})) diff --git a/crates/engine/src/thread_tests.rs b/crates/engine/src/thread_tests.rs index 7a034583f1..3845a64570 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -782,3 +782,118 @@ fn a_blind_tapped_hole_stops_clear_of_its_bottom() { let got = exact_volume(&s, "Block"); assert!((got - want).abs() < before * 1e-4, "{got} vs {want}"); } + +/// A form's groove width at radius r between its root and crest: the pitch less the tooth, the +/// tooth `flat` wide at the crest radius `crest` and widening by tan a + tan b per unit of depth. +fn form_turn(p: f64, flat: f64, crest: f64, a: f64, b: f64, r0: f64, r1: f64, internal: bool) -> f64 { + let t = a.to_radians().tan() + b.to_radians().tan(); + let n = 2000; + let w = |r: f64| p - flat - (if internal { r - crest } else { crest - r }) * t; + (0..n).map(|i| r0 + (r1 - r0) * (i as f64 + 0.5) / n as f64).map(|r| 2.0 * PI * r * w(r) * (r1 - r0) / n as f64).sum() +} + +/// Every standard form cut into a shaft of its major diameter over six turns, right- and +/// left-handed: the groove takes 2π∫ r w(r) dr per turn (Pappus on the section, screw motion), +/// w from the standard's profile, from its external minor diameter to the shaft's surface. +#[test] +fn every_form_cuts_its_standard_profile() { + let inch = 25.4; + // (form, designation, d, P, crest flat, flanks, external minor radius) + let tr_ac = 0.25; + let acme_a = 0.020 * inch; + let t3 = 3f64.to_radians().tan() + 30f64.to_radians().tan(); + let t7 = 7f64.to_radians().tan() + 1.0; + let cases = [ + ("trapezoidal", "Tr20x4", 20.0, 4.0, 2.0 * (1.0 - 15f64.to_radians().tan()), (15.0, 15.0), 10.0 - 2.0 - tr_ac), + ("acme", "3/4-6 ACME", 0.75 * inch, inch / 6.0, inch / 12.0 * (1.0 - 14.5f64.to_radians().tan()), (14.5, 14.5), 0.375 * inch - inch / 12.0 - acme_a / 2.0), + ("buttress", "S20x4", 20.0, 4.0, (4.0 - 3.0 * t3) / 2.0, (3.0, 30.0), 10.0 - 0.86777 * 4.0), + ("buttress_asme", "1.5-8 BUTT", 1.5 * inch, inch / 8.0, (inch / 8.0 - 0.6 * inch / 8.0 * t7) / 2.0, (7.0, 45.0), 0.75 * inch - 0.6 * inch / 8.0), + ]; + for (form, designation, d, p, flat, (a, b), r3) in cases { + let mut volumes = Vec::new(); + for hand in ["right", "left"] { + let mut s = Session::default(); + let len = 6.0 * p; + run(&mut s, "solid.cylinder", json!({"radius": d / 2.0, "height": len, "body_name": "Shaft"})); + let before = exact_volume(&s, "Shaft"); + let th = run(&mut s, "solid.thread", json!({"face": [d / 2.0, 0, len / 2.0], "form": form, "designation": designation, "hand": hand, "modeled": true})); + assert!(th["thread"].is_null(), "{th}"); + let st = s.model.state(); + assert!(st.body("Shaft").unwrap().body.validity().is_empty(), "{designation} {hand}"); + let want = 6.0 * form_turn(p, flat, d / 2.0, a, b, r3, d / 2.0, false); + let got = before - exact_volume(&s, "Shaft"); + assert!((got - want).abs() < want * 1e-4, "{designation} {hand}: removed {got}, the standard's profile {want}"); + volumes.push(got); + } + assert!((volumes[0] - volumes[1]).abs() < volumes[0] * 1e-5, "{designation}: right {} left {}", volumes[0], volumes[1]); + } +} + +/// A left-handed groove turns the other way: on the helix z = P θ / 2π (right-handed) the shaft +/// is cut once a turn, on z = -P θ / 2π it is cut all along. Both: a right- and a left-handed +/// groove, crossing. +#[test] +fn a_left_handed_thread_turns_the_other_way() { + let (d, p, len) = (10.0, 1.5, 9.0); + let r = (d / 2.0 + d / 2.0 - 5.0 * p * 3f64.sqrt() / 16.0) / 2.0; + let on = |s: &Session, sign: f64| -> usize { + let st = s.model.state(); + let body = &st.body("Shaft").unwrap().body; + // Along each helix sense from every groove point on the z = 4.5 circle, count how often + // the line stays in the groove: a groove lies on one of them. + (0..36) + .filter(|i| { + let a0 = *i as f64 * 10f64.to_radians(); + let z0 = 4.5; + let probe = |a: f64| Vec3::new(r * (a0 + a).cos(), r * (a0 + a).sin(), z0 + sign * p * a / std::f64::consts::TAU); + !body.contains(probe(0.0)) && (1..12).all(|k| !body.contains(probe(k as f64 * 0.5))) + }) + .count() + }; + let mut volumes = Vec::new(); + for hand in ["right", "left", "both"] { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": d / 2.0, "height": len, "body_name": "Shaft"})); + let before = exact_volume(&s, "Shaft"); + run(&mut s, "solid.thread", json!({"face": [5, 0, 4.5], "designation": "M10", "hand": hand, "modeled": true})); + assert!(s.model.state().body("Shaft").unwrap().body.validity().is_empty(), "{hand}"); + let (right, left) = (on(&s, 1.0), on(&s, -1.0)); + match hand { + "right" => assert!(right > 0 && left == 0, "right: {right} {left}"), + "left" => assert!(left > 0 && right == 0, "left: {right} {left}"), + _ => assert!(left > 0 && right > 0, "both: {right} {left}"), + } + volumes.push(before - exact_volume(&s, "Shaft")); + } + let want = 6.0 * ext_turn(d, p, d / 2.0); + assert!((volumes[0] - want).abs() < want * 1e-4 && (volumes[1] - want).abs() < want * 1e-4, "{volumes:?} vs {want}"); + // Crossing, the two grooves share some of their volume. + assert!(volumes[2] > want * 1.2 && volumes[2] < 2.0 * want, "{volumes:?}"); +} + +/// A trapezoidal nut: the groove from the drilled minor diameter D1 = d - P out to the +/// internal root D4 = d + 2 ac (ISO 2904), 2π∫ r w(r) dr per turn. +#[test] +fn an_internal_trapezoidal_thread_cuts_out_to_its_root() { + let (d, p, ac) = (20.0, 4.0, 0.25); + let d1 = d - p; + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": 6.0 * p, "body_name": "Nut"})); + run(&mut s, "solid.cylinder", json!({"base": [0, 0, -1], "radius": d1 / 2.0, "height": 6.0 * p + 2.0, "body_name": "Bore"})); + run(&mut s, "solid.combine", json!({"target": "Nut", "tools": ["Bore"], "operation": "cut"})); + let before = exact_volume(&s, "Nut"); + run(&mut s, "solid.thread", json!({"face": [d1 / 2.0, 0, 3.0 * p], "designation": "Tr20x4", "modeled": true})); + let flat = p / 2.0 * (1.0 - 15f64.to_radians().tan()); + let want = 6.0 * form_turn(p, flat, d1 / 2.0, 15.0, 15.0, d1 / 2.0, d / 2.0 + ac, true); + let got = before - exact_volume(&s, "Nut"); + assert!((got - want).abs() < want * 1e-4, "{got} vs {want}"); +} + +/// No current standard defines a square thread: it is refused, saying what to use. +#[test] +fn a_square_thread_is_refused() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20})); + let e = s.execute("solid.thread", &json!({"face": [10, 0, 10], "form": "square", "designation": "20x4"})).err().map(|e| e.to_string()); + assert!(e.unwrap_or_default().contains("no current standard defines a square thread")); +} diff --git a/docs/threads.md b/docs/threads.md new file mode 100644 index 0000000000..8bdfc5f6be --- /dev/null +++ b/docs/threads.md @@ -0,0 +1,88 @@ +# Threads + +`solid.thread` puts a thread on a cylindrical face: cosmetic (an annotation, `model.threads` +lists it) or modelled (the groove between the teeth cut into the body along a helix, SMLib's exact +screw sweep). This page gives the forms, the values each uses and where they come from, and how a +thread's extent is decided. The code is `crates/doc/src/thread_forms.rs` (forms, sizes, +designations) and `crates/doc/src/eval_thread.rs` (the thread on its face, the cut). + +## Forms + +Every groove is a polygon in an axial half-plane: straight flanks, flat crest and root. Crest and +root radii (the ISO metric root radius, the buttress root radius R, DIN 405 knuckle and Whitworth +rounds) are not modelled. External threads are cut from the shaft's surface (a little past it, so +no skin is left) down to the external thread's minor diameter; internal threads from the hole +wall out to the internal thread's root. Pitch-diameter allowances and tolerance classes are not +modelled: a modelled thread is the maximum-material design profile below. + +| Form | `form` | Designation | Flanks | Depth | Crest flat | External minor | Internal minor, root | +|---|---|---|---|---|---|---|---| +| ISO metric | `metric` | `M10`, `M10x1`, `M16xPh3P1.5` | 30° / 30° | 5H/8, H = P √3/2 | P/8 at d, P/4 at D1 | d − 5H/4 | D1 = d − 5H/4, D = d | +| Trapezoidal | `trapezoidal` | `Tr20x4`, `Tr40x14(P7)` | 15° / 15° | H1 = P/2 | P/2 (1 − tan 15°) = 0.366 P | d3 = d − 2 (H1 + ac) | D1 = d − P, D4 = d + 2 ac | +| Acme (general purpose) | `acme` | `1/2-10 ACME` | 14.5° / 14.5° | h = P/2 | P/2 (1 − tan 14.5°) = 0.3707 P | d − P − 0.020″ (0.010″ finer than 10 tpi) | D − P, D + 0.020″ (0.010″) | +| Metric buttress | `buttress` | `S20x4`, `S40x14(P7)` | 3° (load) / 30° | H1 = 0.75 P | (P − 0.75 P (tan 3° + tan 30°))/2 = 0.26384 P | d3 = d − 2 × 0.86777 P | D1 = d − 1.5 P, D = d | +| Buttress inch | `buttress_asme` | `2.5-8 BUTT` | 7° (load) / 45° | h = 0.6 P | (P − 0.6 P (tan 7° + 1))/2 = 0.16316 P | d − 1.2 P (basic) | d − 1.2 P, d (basic) | + +Trapezoidal clearance ac (ISO 2904): 0.15 mm for P = 1.5, 0.25 mm for P 2 to 5, 0.5 mm for P 6 to +12, 1 mm for P 14 to 44. + +The square thread is refused: no current ISO, ASME or DIN standard defines one. Machinery's +Handbook gives only historical proportions (depth and width P/2; the "modified square" 10°); the +trapezoidal and Acme forms replaced it. + +**Sizes.** Without a designation the thread takes the standard size that fits the face: a shaft +its major diameter, a hole its drill (metric: the tap drill d − P; the other forms: the internal +minor diameter). Metric uses the ISO 261/262 coarse pitches (M1.6 to M56); trapezoidal and metric +buttress the ISO 2902 middle pitch of each diameter (Tr8x1.5 to Tr130x14; buttress from S10x2), +the series DIN 513-2 shares; Acme ASME B1.5's preferred sizes (1/4-16 to 5-2). Buttress inch has +no size table here: its pitch is always given. + +**Hand.** `hand: right | left | both`. A left-handed groove is the right-handed one's mirror image +(the screw motion about the reversed axis, rising along it); `both` cuts a right- and a +left-handed groove crossing on the face (a reversing screw). + +**Buttress direction.** `steep_flank: end | start` says which way the steep (load) flank of the +screw's teeth faces along the thread's axis; a nut cut with the same setting mates with it (its +own steep flanks face the other way). + +## Extent + +`extent: full | length | up_to`, `length`, `up_to` (a point's place along the axis, or where a +plane crosses it), `from` (a point near the end the thread starts at). + +A face end is **free** when no material lies past it in the groove's way: over the span from one +pitch to one and a half past the end, within the radii the groove cuts (the external minor +diameter to the major, a nut's minor diameter to its root, and the face itself). A cap, a chamfer +the groove runs out through, a counterbore wider than the thread are free; a shoulder, a head +whose corners reach past the root, a blind hole's bottom are not. + +- At a free end the groove runs out a pitch past the end, into the air. +- At a closed end it stops with its section clear of the end: its centre as far inside as the + section reaches that way, so it never cuts into the shoulder. +- Inside the face it stops square. For a symmetric section a square stop inside material removes + exactly length / P turns (what the last section leaves uncut ahead of the stop, it cuts behind + it in mirror image). + +A thread of some length starts at the end nearest `from`; without it, at the face's only free end; +without one, at the end the `face` point lies within a third of the face of. A pick in the middle +of a face whose ends are alike is refused, naming `from`. A length as long as the face runs the +whole face. + +## Sources + +- ISO 68-1 (basic profile), ISO 261/262 (sizes), ISO 965-1 (multi-start designation `Ph`). +- ISO 2901 to 2904 (trapezoidal: basic and design profiles, series, basic dimensions); the + design diameters checked against the Bornemann Gewindetechnik tables of DIN 103 + (Tr10x2: d3 7.5, D4 10.5, D1 8; Tr40x7: 32, 41, 33; Tr100x20: 78, 102, 80): + . +- DIN 513-1/-2 (metric buttress): the formulas (ac = 0.11777 P, h3 = 0.86777 P, flat w = + 0.26384 P, D1 = d − 2 H1) and the diameter-pitch table as IS 4696 (Part 3): 2004 publishes them + in line with DIN 513 (), and the + Bornemann tables (S10x2: d3 6.529, D1 7; S40x7: 27.851, 29.5): + . +- ASME B1.5 (Acme): flat 0.3707 P, h = P/2, the 0.020″/0.010″ minor and major allowances and the + preferred sizes, as reproduced at . +- ASME B1.9 (buttress inch): 7°/45°, basic height of engagement 0.6 P; the flat follows from the + fundamental triangle H = P / (tan 7° + tan 45°) = 0.89064 P truncated equally at crest and root. +- ISO 3508 (thread runouts) and ISO 4753 (ends of parts with external threads) for the runout and + the chamfered tip. diff --git a/tests/kernel-cases/ladder/06-threads/011-external-tr20x4/expected.json b/tests/kernel-cases/ladder/06-threads/011-external-tr20x4/expected.json new file mode 100644 index 0000000000..155ab9f778 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/011-external-tr20x4/expected.json @@ -0,0 +1,11 @@ +{ + "sources": [], + "what": "a modelled trapezoidal Tr20x4 thread (ISO 2904) on a d20 shaft, six turns", + "source": "analytic", + "method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ISO 2904: 30 deg, H1 = P/2, crest flat P/2 (1 - tan15) = 0.366 P, external minor d3 = d - 2 (H1 + ac) = 15.5 (ac = 0.25 for P 2..5)", + "body_count": 1, + "volume_mm3": 6065.46543972, + "tolerance": { + "rel": 0.0001 + } +} diff --git a/tests/kernel-cases/ladder/06-threads/011-external-tr20x4/recipe.json b/tests/kernel-cases/ladder/06-threads/011-external-tr20x4/recipe.json new file mode 100644 index 0000000000..bd5f116e96 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/011-external-tr20x4/recipe.json @@ -0,0 +1,30 @@ +{ + "commands": [ + { + "command": "solid.cylinder", + "params": { + "base": [ + 0, + 0, + 0 + ], + "radius": 10.0, + "height": 24, + "body_name": "Part" + } + }, + { + "command": "solid.thread", + "params": { + "face": [ + 10.0, + 0, + 12.0 + ], + "modeled": true, + "form": "trapezoidal", + "designation": "Tr20x4" + } + } + ] +} diff --git a/tests/kernel-cases/ladder/06-threads/012-external-acme-3-4-6/expected.json b/tests/kernel-cases/ladder/06-threads/012-external-acme-3-4-6/expected.json new file mode 100644 index 0000000000..9ef0f2b697 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/012-external-acme-3-4-6/expected.json @@ -0,0 +1,11 @@ +{ + "sources": [], + "what": "a modelled 3/4-6 ACME thread (ASME B1.5, general purpose) on a 3/4 in shaft, six turns", + "source": "analytic", + "method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ASME B1.5: 29 deg, h = P/2, crest flat 0.3707 P, external minor d - P - 0.020 in (10 threads per inch or coarser)", + "body_count": 1, + "volume_mm3": 5689.272256144, + "tolerance": { + "rel": 0.0001 + } +} diff --git a/tests/kernel-cases/ladder/06-threads/012-external-acme-3-4-6/recipe.json b/tests/kernel-cases/ladder/06-threads/012-external-acme-3-4-6/recipe.json new file mode 100644 index 0000000000..110f52c8c8 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/012-external-acme-3-4-6/recipe.json @@ -0,0 +1,30 @@ +{ + "commands": [ + { + "command": "solid.cylinder", + "params": { + "base": [ + 0, + 0, + 0 + ], + "radius": 9.524999999999999, + "height": 25.4, + "body_name": "Part" + } + }, + { + "command": "solid.thread", + "params": { + "face": [ + 9.524999999999999, + 0, + 12.7 + ], + "modeled": true, + "form": "acme", + "designation": "3/4-6 ACME" + } + } + ] +} diff --git a/tests/kernel-cases/ladder/06-threads/013-external-s20x4/expected.json b/tests/kernel-cases/ladder/06-threads/013-external-s20x4/expected.json new file mode 100644 index 0000000000..8d58544496 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/013-external-s20x4/expected.json @@ -0,0 +1,11 @@ +{ + "sources": [], + "what": "a modelled metric buttress S20x4 thread (DIN 513, 3/30 deg) on a d20 shaft, six turns", + "source": "analytic", + "method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. DIN 513-1: 3 deg load flank, 30 deg clearance flank, H1 = 0.75 P, flats (P - 0.75 P (tan3 + tan30))/2 = 0.26384 P, external minor d3 = d - 2 x 0.86777 P = 13.058 (IS 4696-3 table)", + "body_count": 1, + "volume_mm3": 5454.579522681, + "tolerance": { + "rel": 0.0001 + } +} diff --git a/tests/kernel-cases/ladder/06-threads/013-external-s20x4/recipe.json b/tests/kernel-cases/ladder/06-threads/013-external-s20x4/recipe.json new file mode 100644 index 0000000000..5b0fed8d96 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/013-external-s20x4/recipe.json @@ -0,0 +1,30 @@ +{ + "commands": [ + { + "command": "solid.cylinder", + "params": { + "base": [ + 0, + 0, + 0 + ], + "radius": 10.0, + "height": 24, + "body_name": "Part" + } + }, + { + "command": "solid.thread", + "params": { + "face": [ + 10.0, + 0, + 12.0 + ], + "modeled": true, + "form": "buttress", + "designation": "S20x4" + } + } + ] +} diff --git a/tests/kernel-cases/ladder/06-threads/014-external-buttress-asme-1.5-8/expected.json b/tests/kernel-cases/ladder/06-threads/014-external-buttress-asme-1.5-8/expected.json new file mode 100644 index 0000000000..8f6563ae78 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/014-external-buttress-asme-1.5-8/expected.json @@ -0,0 +1,11 @@ +{ + "sources": [], + "what": "a modelled 1.5-8 buttress thread (ASME B1.9 basic profile, 7/45 deg) on a 1.5 in shaft, six turns", + "source": "analytic", + "method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ASME B1.9 basic profile: 7 deg load flank, 45 deg clearance flank, h = 0.6 P, flats (P - 0.6 P (tan7 + tan45))/2 = 0.16316 P; the root radius and allowances are not modelled", + "body_count": 1, + "volume_mm3": 19631.082660727, + "tolerance": { + "rel": 0.0001 + } +} diff --git a/tests/kernel-cases/ladder/06-threads/014-external-buttress-asme-1.5-8/recipe.json b/tests/kernel-cases/ladder/06-threads/014-external-buttress-asme-1.5-8/recipe.json new file mode 100644 index 0000000000..20a81be14e --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/014-external-buttress-asme-1.5-8/recipe.json @@ -0,0 +1,30 @@ +{ + "commands": [ + { + "command": "solid.cylinder", + "params": { + "base": [ + 0, + 0, + 0 + ], + "radius": 19.049999999999997, + "height": 19.049999999999997, + "body_name": "Part" + } + }, + { + "command": "solid.thread", + "params": { + "face": [ + 19.049999999999997, + 0, + 9.524999999999999 + ], + "modeled": true, + "form": "buttress_asme", + "designation": "1.5-8 BUTT" + } + } + ] +} diff --git a/tests/kernel-cases/ladder/06-threads/015-internal-tr20x4-nut/expected.json b/tests/kernel-cases/ladder/06-threads/015-internal-tr20x4-nut/expected.json new file mode 100644 index 0000000000..d1f317fa08 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/015-internal-tr20x4-nut/expected.json @@ -0,0 +1,11 @@ +{ + "sources": [], + "what": "a modelled internal Tr20x4 thread in a d16 (D1 = d - P) bore through a 40x40x24 block", + "source": "analytic", + "method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns through the block, both ends free, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ISO 2904 internal: the nut's crest flat 0.366 P at D1 = d - P = 16, its root at D4 = d + 2 ac = 20.5; the groove from the bore wall r 8 out to r 10.25, w(r) = P - 0.366 P - (r - 8) 2 tan15", + "body_count": 1, + "volume_mm3": 32097.519700251, + "tolerance": { + "rel": 0.0001 + } +} diff --git a/tests/kernel-cases/ladder/06-threads/015-internal-tr20x4-nut/recipe.json b/tests/kernel-cases/ladder/06-threads/015-internal-tr20x4-nut/recipe.json new file mode 100644 index 0000000000..799a378354 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/015-internal-tr20x4-nut/recipe.json @@ -0,0 +1,53 @@ +{ + "commands": [ + { + "command": "solid.box", + "params": { + "corner": [ + -20, + -20, + 0 + ], + "length": 40, + "width": 40, + "height": 24, + "body_name": "Part" + } + }, + { + "command": "solid.cylinder", + "params": { + "base": [ + 0, + 0, + -1 + ], + "radius": 8, + "height": 26, + "body_name": "Bore" + } + }, + { + "command": "solid.combine", + "params": { + "target": "Part", + "tools": [ + "Bore" + ], + "operation": "cut" + } + }, + { + "command": "solid.thread", + "params": { + "face": [ + 8, + 0, + 12 + ], + "modeled": true, + "designation": "Tr20x4" + } + } + ] +} diff --git a/tests/kernel-cases/ladder/06-threads/016-external-m10-left-hand/expected.json b/tests/kernel-cases/ladder/06-threads/016-external-m10-left-hand/expected.json new file mode 100644 index 0000000000..8d8bf72ef6 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/016-external-m10-left-hand/expected.json @@ -0,0 +1,11 @@ +{ + "sources": [], + "what": "001's modelled M10 thread, left-handed: the same volume, turning the other way", + "source": "analytic", + "method": "ISO 68-1 basic profile (60 deg, root flat P/4 at d - 5H/4 ... minor d1 = d - 1.0825 P, crest flat P/8): a helical groove of fixed section is a screw motion, so averaged over the turn every meridian half-plane holds the same section and V = 2 pi int int r m(r) dr dz over the stock's section, m the material fraction per pitch; the groove runs out past free ends. Tolerance 0.01%: SMLib's exact screw sweep; a mirror image of 001's groove, so the same volume", + "body_count": 1, + "volume_mm3": 1954.24591725, + "tolerance": { + "rel": 0.0001 + } +} diff --git a/tests/kernel-cases/ladder/06-threads/016-external-m10-left-hand/recipe.json b/tests/kernel-cases/ladder/06-threads/016-external-m10-left-hand/recipe.json new file mode 100644 index 0000000000..99bb3b541a --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/016-external-m10-left-hand/recipe.json @@ -0,0 +1,30 @@ +{ + "commands": [ + { + "command": "solid.cylinder", + "params": { + "base": [ + 0, + 0, + 0 + ], + "radius": 5, + "height": 30, + "body_name": "Part" + } + }, + { + "command": "solid.thread", + "params": { + "face": [ + 5, + 0, + 15 + ], + "modeled": true, + "designation": "M10", + "hand": "left" + } + } + ] +} diff --git a/tests/kernel-cases/ladder/ladder.json b/tests/kernel-cases/ladder/ladder.json index 66cb900ccd..328acb90a3 100644 --- a/tests/kernel-cases/ladder/ladder.json +++ b/tests/kernel-cases/ladder/ladder.json @@ -107,7 +107,13 @@ "ladder/06-threads/006-external-m10-into-chamfer", "ladder/06-threads/002-external-m10x1-fine", "ladder/06-threads/008-multi-start-thread", - "ladder/06-threads/009-thread-runout-length" + "ladder/06-threads/009-thread-runout-length", + "ladder/06-threads/011-external-tr20x4", + "ladder/06-threads/012-external-acme-3-4-6", + "ladder/06-threads/013-external-s20x4", + "ladder/06-threads/014-external-buttress-asme-1.5-8", + "ladder/06-threads/015-internal-tr20x4-nut", + "ladder/06-threads/016-external-m10-left-hand" ], "tier": "06-threads", "what": "modelled threads" diff --git a/tools/kernel-ladder/cases.py b/tools/kernel-ladder/cases.py index df733b1167..69bc11b8a9 100644 --- a/tools/kernel-ladder/cases.py +++ b/tools/kernel-ladder/cases.py @@ -406,6 +406,51 @@ case(T6, "010-part-threaded-boss", "the running part with a modelled M16 thread [thr([23, 15, 25], designation="M16")], {"body_count": 1}, "the boss stands on the base (thread to a shoulder) and has an M5 hole in its top; modelled threads are faceted, so later tiers build on the part before this step and 11-cumulative-parts adds it last", base=C5, source="invariants") +# The other standard forms (crates/doc/src/thread_forms.rs, docs/threads.md): a groove of a fixed +# straight-flanked section, its tooth `flat` wide at the crest radius and widening by tan a + tan b +# per unit of depth, removes 2 pi int r w(r) dr per turn (Pappus, the screw motion). +def form_turn(P, flat, crest, a, b, r0, r1, internal=False): + t = math.tan(math.radians(a)) + math.tan(math.radians(b)) + w = lambda r: P - flat - ((r - crest) if internal else (crest - r)) * t + return 2 * PI * quad(lambda r: r * w(r), r0, r1, 16) +INCH = 25.4 +TOL_F = {"rel": 1e-4} +FMETH = ("groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); " + "six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ") +def shaft_form(name, what, form, desig, d, P, flat, a, b, r3, extra="", **p): + L = 6 * P + v = PI * (d / 2) ** 2 * L - 6 * form_turn(P, flat, d / 2, a, b, r3, d / 2) + case(T6, name, what, [cyl([0, 0, 0], d / 2, L, body_name="Part"), thr([d / 2, 0, L / 2], form=form, designation=desig, **p)], + {"body_count": 1, "volume_mm3": v, "tolerance": TOL_F}, FMETH + extra) +t15, t145 = math.tan(math.radians(15)), math.tan(math.radians(14.5)) +shaft_form("011-external-tr20x4", "a modelled trapezoidal Tr20x4 thread (ISO 2904) on a d20 shaft, six turns", "trapezoidal", "Tr20x4", 20, 4, + 2 * (1 - t15), 15, 15, 10 - 2 - 0.25, + "ISO 2904: 30 deg, H1 = P/2, crest flat P/2 (1 - tan15) = 0.366 P, external minor d3 = d - 2 (H1 + ac) = 15.5 (ac = 0.25 for P 2..5)") +shaft_form("012-external-acme-3-4-6", "a modelled 3/4-6 ACME thread (ASME B1.5, general purpose) on a 3/4 in shaft, six turns", "acme", "3/4-6 ACME", 0.75 * INCH, INCH / 6, + INCH / 12 * (1 - t145), 14.5, 14.5, 0.375 * INCH - INCH / 12 - 0.010 * INCH, + "ASME B1.5: 29 deg, h = P/2, crest flat 0.3707 P, external minor d - P - 0.020 in (10 threads per inch or coarser)") +t3 = math.tan(math.radians(3)) + math.tan(math.radians(30)) +shaft_form("013-external-s20x4", "a modelled metric buttress S20x4 thread (DIN 513, 3/30 deg) on a d20 shaft, six turns", "buttress", "S20x4", 20, 4, + (4 - 3 * t3) / 2, 3, 30, 10 - 0.86777 * 4, + "DIN 513-1: 3 deg load flank, 30 deg clearance flank, H1 = 0.75 P, flats (P - 0.75 P (tan3 + tan30))/2 = 0.26384 P, external minor d3 = d - 2 x 0.86777 P = 13.058 (IS 4696-3 table)") +t7 = math.tan(math.radians(7)) + 1 +P8 = INCH / 8 +shaft_form("014-external-buttress-asme-1.5-8", "a modelled 1.5-8 buttress thread (ASME B1.9 basic profile, 7/45 deg) on a 1.5 in shaft, six turns", "buttress_asme", "1.5-8 BUTT", + 1.5 * INCH, P8, (P8 - 0.6 * P8 * t7) / 2, 7, 45, 0.75 * INCH - 0.6 * P8, + "ASME B1.9 basic profile: 7 deg load flank, 45 deg clearance flank, h = 0.6 P, flats (P - 0.6 P (tan7 + tan45))/2 = 0.16316 P; the root radius and allowances are not modelled") +dT, PT = 20, 4 +vnut = 6 * form_turn(PT, 2 * (1 - t15), (dT - PT) / 2, 15, 15, (dT - PT) / 2, dT / 2 + 0.25, internal=True) +case(T6, "015-internal-tr20x4-nut", "a modelled internal Tr20x4 thread in a d16 (D1 = d - P) bore through a 40x40x24 block", + [box([-20, -20, 0], 40, 40, 24, body_name="Part"), cyl([0, 0, -1], 8, 26, body_name="Bore"), C("solid.combine", target="Part", tools=["Bore"], operation="cut"), + thr([8, 0, 12], designation="Tr20x4")], + {"body_count": 1, "volume_mm3": 40 * 40 * 24 - PI * 64 * 24 - vnut, "tolerance": TOL_F}, + FMETH.replace("between free ends", "through the block, both ends free") + "ISO 2904 internal: the nut's crest flat 0.366 P at D1 = d - P = 16, its root at D4 = d + 2 ac = 20.5; " + "the groove from the bore wall r 8 out to r 10.25, w(r) = P - 0.366 P - (r - 8) 2 tan15") +case(T6, "016-external-m10-left-hand", "001's modelled M10 thread, left-handed: the same volume, turning the other way", cylT + [thr([5, 0, 15], designation="M10", hand="left")], + {"body_count": 1, "volume_mm3": ext_volume(10, 1.5, [(0, 30, lambda z: 5)]), "tolerance": {"rel": 1e-4}}, + TMETH.replace("Tolerance 0.5%: solid.thread cuts faceted by design (chords within P/200 on the body, P/100 on the tool)", "Tolerance 0.01%: SMLib's exact screw sweep") + + "; a mirror image of 001's groove, so the same volume") + # ---------------------------------------------------------------- 07 fillets F = lambda edges, r, **p: C("solid.fillet", edges=edges, radius=r, **p) case(T7, "001-single-edge-r5", "an r5 round on the stock's top front edge", [F([[30, 0, 20]], 5)],