Threads in the standard forms, right- or left-handed or both

Only the ISO metric 60° thread existed. Forms now (crates/doc/src/thread_forms.rs, values and
sources in docs/threads.md): ISO metric (ISO 68-1), trapezoidal (ISO 2904 design profile, ISO
2902 sizes), general purpose Acme (ASME B1.5), metric buttress 3°/30° (DIN 513), buttress inch
7°/45° (ASME B1.9 basic profile). Each has its groove as a straight-flanked polygon, its standard
sizes (none for ASME buttress), its designations (M10, M16xPh3P1.5, Tr40x14(P7), S20x4,
1/2-10 ACME, 2.5-8 BUTT) and the size that fits a face. The square thread is refused: no current
standard defines it.

`hand: right | left | both`: a left-handed groove is the screw motion about the reversed axis
rising along it; both cuts the two grooves, crossing, in one boolean. `steep_flank` turns a
buttress. The design diameters are checked against the published tables (ISO 2904/DIN 103,
DIN 513 via IS 4696-3); every form's cut against 2π∫ r w(r) dr per turn to 1e-4 in engine tests
and to 1e-4 in ladder 06/011 to 016 (they measure within 5e-7).

FeatureKind::Thread holds every option the command has (form, hand, starts, ease_in, ease_out,
tip, chamfer_angle, chamfer_depth, steep_flank, all defaulted so saved threads load unchanged);
starts, runouts and the chamfered tip are refused as not built yet. suggested_thread_in takes the
form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-26 09:19:00 -04:00
parent 366b90597f
commit b4549d2700
23 changed files with 1449 additions and 159 deletions
+38
View File
@@ -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<ThreadLimit>,
/// 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<ThreadRunout>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ease_out: Option<ThreadRunout>,
/// 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<Expr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Chamfer depth")]
chamfer_depth: Option<Expr>,
/// 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")]
+2 -2
View File
@@ -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
+191 -149
View File
@@ -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::<f64>().ok()?, Some(p.trim().parse::<f64>().ok()?)),
None => (body.trim().parse::<f64>().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<String> {
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<String> {
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<ThreadForm>,
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<Vec3>,
@@ -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<f64>, at: f64) -> Result<usize>
}
}
/// 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<Vec2>,
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<GrooveShape> {
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<Vec<GrooveShape>> {
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<String, Value>, 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, &section, 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, &section, g.base, g.axis, g.pitch, g.turns)?);
}
let tool = match grooves.as_slice() {
[one] => one.clone(),
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
};
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);
+2 -1
View File
@@ -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};
+606
View File
@@ -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: Default + PartialEq>(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> {
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<ThreadForm> {
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<String> {
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<ThreadSize, String> {
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<f64> {
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<f64> {
let s = s.trim();
let v = match s.split_once('/') {
Some((a, b)) => a.parse::<f64>().ok()? / b.parse::<f64>().ok()?,
None => s.parse::<f64>().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<f64> {
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:?}");
}
}
}
+4
View File
@@ -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 {
+82 -6
View File
@@ -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<Value> {
.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<Option<solidcraft_doc::Expr>> {
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<Option<solidcraft_doc::ThreadRunout>> {
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<Value> {
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}))
+115
View File
@@ -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"));
}
+88
View File
@@ -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):
<https://www.bornemann-gewindetechnik.de/de/produkte/trapezgewinde/daten/>.
- 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 (<https://law.resource.org/pub/in/bis/S01/is.4696.3.2004.pdf>), and the
Bornemann tables (S10x2: d3 6.529, D1 7; S40x7: 27.851, 29.5):
<https://www.bornemann-gewindetechnik.de/de/produkte/saegengewinde/daten/>.
- 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 <https://torqbolt.com/unified-acme-threads-dimensions-specifications>.
- 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.
@@ -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
}
}
@@ -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"
}
}
]
}
@@ -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
}
}
@@ -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"
}
}
]
}
@@ -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
}
}
@@ -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"
}
}
]
}
@@ -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
}
}
@@ -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"
}
}
]
}
@@ -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
}
}
@@ -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"
}
}
]
}
@@ -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
}
}
@@ -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"
}
}
]
}
+7 -1
View File
@@ -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"
+45
View File
@@ -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)],