mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Thread: ISO 228-1, ISO 7-1 and NPT pipe threads, taper threads by a radial law
Three forms: `bspp` (G, ISO 228-1, 55° parallel), `bspt` (R external, Rc internal, ISO 7-1, 55° taper 1 in 16) and `npt` (ASME B1.20.1, 60° taper 1 in 16). Their sizes are tabulated from the public sources: ISO 7-1:1978 Table 1 (its gauge-plane diameters are the G sizes) and NBS Handbook H28 (1957) Table VII.2; tests check every row against the standards' own relations. A taper thread starts at its free end with its standard diameter there (external: the gauge diameter its gauge length in, internal: at the face). Its groove is the screw sweep with the section moved 1/32 of the axial travel off the axis (the radial law the runouts use, added to it), its section reaching just past the crest, and the material beyond the major cone (or inside the minor cone) is cut with it, as a die or tap cuts the crests. An external one runs its standard length by default. The volumes are checked against the profile's material fraction integrated over the tapered radii: G 1/2 to 2e-6, R 1/2, Rc 1/2 and 1/2-14 NPT within 0.2 % of the cut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+179
-11
@@ -201,7 +201,24 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
return Err(DocError::Invalid(format!("`{}` has {named} start(s), not {starts}", ask.designation.unwrap_or_default())));
|
||||
}
|
||||
let starts = named.max(1);
|
||||
let designation = tapped.as_ref().map(|t| t.designation.clone()).unwrap_or_else(|| form.format(major, pitch, size.lead));
|
||||
let mut designation = tapped.as_ref().map(|t| t.designation.clone()).unwrap_or_else(|| form.format(major, pitch, size.lead));
|
||||
if !form.pipe_sizes().is_empty() && tapped.is_none() {
|
||||
if ask.hand == ThreadHand::Both {
|
||||
return Err(DocError::Invalid("a pipe thread turns one way: right or left".into()));
|
||||
}
|
||||
// ISO 7-1 names the internal taper thread Rc and the external one R.
|
||||
if form == ThreadForm::Bspt {
|
||||
if let Some(given) = ask.designation
|
||||
&& ThreadForm::pipe_internal(given) != cyl.internal
|
||||
{
|
||||
let want = if cyl.internal { "an internal taper thread is Rc" } else { "an external taper thread is R" };
|
||||
return Err(DocError::Invalid(format!("`{given}` on this {}: {want} (ISO 7-1)", if cyl.internal { "hole" } else { "shaft" })));
|
||||
}
|
||||
if cyl.internal {
|
||||
designation = designation.replacen("R ", "Rc ", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let minor = 2.0 * form.groove(major, pitch, true).crest_r;
|
||||
let fits = if let Some(t) = tapped.as_ref().filter(|t| t.family != crate::TappedThreadFamily::Metric) {
|
||||
// The hole drilled for it, without borrowing an ISO minor-diameter rule.
|
||||
@@ -234,7 +251,13 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
Ok((a, b))
|
||||
};
|
||||
let from_side = |ends: &mut FaceEnds| start_side(ends, ask.from.map(along), along(p));
|
||||
let (a, b, side) = match ask.extent {
|
||||
// An external taper thread runs its standard length (ISO 7-1's useful thread, NPT's
|
||||
// effective thread L2) unless told otherwise; an internal one the face, as a tapped hole.
|
||||
let extent = match (ask.extent, form.is_taper() && !cyl.internal) {
|
||||
(Extent::Full, true) => form.pipe_size(major).map_or(Extent::Full, |z| Extent::Length(z.length)),
|
||||
(e, _) => e,
|
||||
};
|
||||
let (a, b, side) = match extent {
|
||||
Extent::Full => (lo, hi, ask.from.map(|q| usize::from((along(q) - lo).abs() > (hi - along(q)).abs())).unwrap_or(0)),
|
||||
Extent::Length(l) => {
|
||||
if !(l > 0.0) {
|
||||
@@ -288,6 +311,12 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
})
|
||||
};
|
||||
let ends = [kind(a, 0)?, kind(b, 1)?];
|
||||
if form.is_taper() && ask.modeled && ends.get(side) != Some(&End::Free) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"a taper pipe thread starts at a free end, the pipe's end or the {}: start it there (`from`, a point near that end)",
|
||||
if cyl.internal { "hole's mouth" } else { "shaft's end" }
|
||||
)));
|
||||
}
|
||||
let info = ThreadInfo {
|
||||
feature,
|
||||
body: mb.name.clone(),
|
||||
@@ -353,8 +382,18 @@ struct GrooveShape {
|
||||
/// 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, ease: [f64; 2]) -> Result<Vec<GrooveShape>> {
|
||||
/// along the axis and `ends` how the thread's two ends meet it. A taper thread's `taper` is
|
||||
/// (its major diameter where it starts, the end of the axis it starts from): its diameters grow
|
||||
/// 1 in 16 from there into an external thread's material and shrink into an internal one's.
|
||||
fn thread_grooves(
|
||||
t: &ThreadInfo,
|
||||
radius: f64,
|
||||
face: (f64, f64),
|
||||
ends: [End; 2],
|
||||
steep: SteepFlank,
|
||||
ease: [f64; 2],
|
||||
taper: Option<(f64, usize)>,
|
||||
) -> 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;
|
||||
@@ -367,10 +406,24 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
|
||||
190.0 * lead
|
||||
)));
|
||||
}
|
||||
let mut g = form.groove(d, p, t.internal);
|
||||
if steep == SteepFlank::Start {
|
||||
std::mem::swap(&mut g.flank_lo, &mut g.flank_hi);
|
||||
}
|
||||
// The major diameter at axial position z: fixed, or a taper's from where it starts.
|
||||
let major_at = |z: f64| match taper {
|
||||
Some((d0, from)) => {
|
||||
let (z0, into) = if from == 0 { (t.start, 1.0) } else { (t.end, -1.0) };
|
||||
let grow = if t.internal { -1.0 } else { 1.0 };
|
||||
d0 + grow * into * (z - z0) / 16.0
|
||||
}
|
||||
None => d,
|
||||
};
|
||||
let d = taper.map_or(d, |(d0, _)| d0);
|
||||
let profile = |d: f64| {
|
||||
let mut g = form.groove(d, p, t.internal);
|
||||
if steep == SteepFlank::Start {
|
||||
std::mem::swap(&mut g.flank_lo, &mut g.flank_hi);
|
||||
}
|
||||
g
|
||||
};
|
||||
let g = profile(d);
|
||||
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
|
||||
@@ -393,7 +446,17 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
|
||||
}
|
||||
// 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) };
|
||||
// A taper thread's crests are cut by its major cone (external) or minor cone (internal),
|
||||
// as the die or tap cuts them ([`taper_envelope`]): its groove reaches just past the crest,
|
||||
// by as much as narrows the tooth's crest flat by a quarter (NPT's flat is 0.038 P).
|
||||
let past = |g: &crate::thread_forms::GrooveProfile| 0.25 * g.crest_flat / (g.flank_lo.tan() + g.flank_hi.tan());
|
||||
let span = |g: &crate::thread_forms::GrooveProfile| match (taper.is_some(), t.internal) {
|
||||
(true, true) => (g.crest_r - past(g), g.root_r),
|
||||
(true, false) => (g.root_r, g.crest_r + past(g)),
|
||||
(false, true) => (radius.min(g.crest_r) - 0.1 * p, g.root_r),
|
||||
(false, false) => (g.root_r, radius.max(d / 2.0) + 0.05 * p),
|
||||
};
|
||||
let (r0, r1) = span(&g);
|
||||
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
|
||||
@@ -410,7 +473,7 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
|
||||
}
|
||||
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)];
|
||||
let mut 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()));
|
||||
}
|
||||
@@ -443,6 +506,20 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
|
||||
)));
|
||||
}
|
||||
let turns = (s1 - s0) / lead;
|
||||
// A taper thread's section is the profile where the sweep begins, moved off the axis as it
|
||||
// turns by the taper's 1/32 of the axial travel.
|
||||
let mut r0 = r0;
|
||||
let taper_rate = match taper {
|
||||
Some(_) => {
|
||||
let gb = profile(major_at(s0));
|
||||
let (a, b) = span(&gb);
|
||||
let ((l0, h0), (l1, h1)) = (gb.extents(p, a), gb.extents(p, b));
|
||||
section = vec![Vec2::new(a, -l0), Vec2::new(b, -l1), Vec2::new(b, h1), Vec2::new(a, h0)];
|
||||
r0 = a;
|
||||
(major_at(s0 + lead) - major_at(s0)) / 2.0
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
// How far the section moves to leave the material, a little past it.
|
||||
let out = if t.internal { -(r1 - radius + 0.05 * p) } else { radius - r0 + 0.05 * p };
|
||||
if t.internal && r0 + out < 0.1 * p {
|
||||
@@ -459,6 +536,12 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
|
||||
if r_in > 0.0 && r_out > 0.0 && !(turns - r_out > r_in + 1e-9) || r_in.max(r_out) > turns {
|
||||
return Err(DocError::Invalid(format!("the runouts ({} and {} mm) are longer than the thread", show_mm(ease[0]), show_mm(ease[1]))));
|
||||
}
|
||||
if taper_rate != 0.0 {
|
||||
law = with_taper(&law, turns, taper_rate);
|
||||
if t.internal && law.iter().any(|(_, o)| r0 + o < 0.1 * p) {
|
||||
return Err(DocError::Invalid("the hole is too small for this taper thread".into()));
|
||||
}
|
||||
}
|
||||
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()))?;
|
||||
@@ -493,6 +576,74 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
|
||||
Ok(grooves)
|
||||
}
|
||||
|
||||
/// A radial law (turn, offset) with a taper's `rate` mm per turn added: the law's own points and
|
||||
/// both ends, each offset the law's (held past its first and last points) plus the taper's.
|
||||
fn with_taper(law: &[(f64, f64)], turns: f64, rate: f64) -> Vec<(f64, f64)> {
|
||||
let at = |t: f64| -> f64 {
|
||||
match (law.first(), law.last()) {
|
||||
(Some(a), _) if t <= a.0 => a.1,
|
||||
(_, Some(b)) if t >= b.0 => b.1,
|
||||
_ => law
|
||||
.windows(2)
|
||||
.find_map(|w| match w {
|
||||
[a, b] if t >= a.0 && t <= b.0 && b.0 > a.0 => Some(a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0.0),
|
||||
}
|
||||
};
|
||||
let mut ts: Vec<f64> = law.iter().map(|(t, _)| *t).chain([0.0, turns]).collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
|
||||
ts.into_iter().map(|t| (t, at(t) + rate * t)).collect()
|
||||
}
|
||||
|
||||
/// A taper thread's crests, as its die or tap cuts them: the ring of material outside its major
|
||||
/// cone (external) or inside its minor cone (internal) along the thread, from a pitch past the
|
||||
/// end it starts at (`from`, a free end; `d0` its major diameter there) to its other end (a pitch
|
||||
/// past that too when it is free). None when the cone misses the material.
|
||||
fn taper_envelope(t: &ThreadInfo, radius: f64, ends: [End; 2], (d0, from): (f64, usize)) -> Result<Option<Body>> {
|
||||
let p = t.pitch;
|
||||
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 (at, into) = if from == 0 { (t.start, k) } else { (t.end, -k) };
|
||||
let far = 1 - from.min(1);
|
||||
let len = t.end - t.start + if ends.get(far) == Some(&End::Free) { p } else { 0.0 };
|
||||
let e = p;
|
||||
// In the plane: x radial, y along the axis into the material from the start.
|
||||
let plane = Plane { origin: t.axis_point + k * at, x: u, y: into };
|
||||
let pts = if t.internal {
|
||||
// The minor cone shrinks into the hole: what lies inside it, down to well inside the hole.
|
||||
let minor0 = t.form.groove(d0, p, true).crest_r;
|
||||
let rm = |y: f64| minor0 - y / 32.0;
|
||||
if !(rm(-e) > radius) {
|
||||
return Ok(None);
|
||||
}
|
||||
let r_in = 0.5 * radius.min(rm(len));
|
||||
if !(r_in > 0.0) {
|
||||
return Err(DocError::Invalid("the hole is too small for this taper thread".into()));
|
||||
}
|
||||
vec![Vec2::new(r_in, -e), Vec2::new(rm(-e), -e), Vec2::new(rm(len), len), Vec2::new(r_in, len)]
|
||||
} else {
|
||||
// The major cone grows into the shaft: what lies outside it, out past the surface.
|
||||
let rc = |y: f64| d0 / 2.0 + y / 32.0;
|
||||
let r_out = radius + 0.1 * p;
|
||||
if !(rc(-e) < r_out) {
|
||||
return Ok(None);
|
||||
}
|
||||
let hit = (r_out - d0 / 2.0) * 32.0;
|
||||
if hit < len {
|
||||
vec![Vec2::new(rc(-e), -e), Vec2::new(r_out, hit), Vec2::new(r_out, -e)]
|
||||
} else {
|
||||
vec![Vec2::new(rc(-e), -e), Vec2::new(rc(len), len), Vec2::new(r_out, len), Vec2::new(r_out, -e)]
|
||||
}
|
||||
};
|
||||
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: Vec::new() };
|
||||
let mut ring = kernel::revolve(&plane, &[region], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), std::f64::consts::TAU)?;
|
||||
Ok(ring.pop())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
@@ -553,6 +704,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
let ask = ThreadAsk { ease: [runout(ease_in)?, runout(ease_out)?], ..ask };
|
||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side } = thread_on(st, f.id, face.0, &ask)?;
|
||||
if *tip != ThreadTip::None && t.form.is_taper() {
|
||||
return Err(DocError::Invalid("not supported yet: a chamfered tip on a taper pipe thread".into()));
|
||||
}
|
||||
if !*modeled {
|
||||
st.threads.push(t);
|
||||
return Ok(());
|
||||
@@ -561,11 +715,25 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let (input, radius) = (mb.traced(), cyl.radius);
|
||||
// The exact grooves: SMLib's screw sweeps, cut by one B-rep boolean.
|
||||
// A taper thread starts at its free end at the major diameter its standard puts there: an
|
||||
// internal one's gauge (reference) plane at its face, an external one's that far in.
|
||||
let taper = match t.form.is_taper() {
|
||||
true => {
|
||||
let z = t.form.pipe_size(t.major_diameter).ok_or_else(|| DocError::Invalid(format!("no pipe size {}", t.designation)))?;
|
||||
Some((if t.internal { t.major_diameter } else { t.major_diameter - z.gauge / 16.0 }, from_side))
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
let mut grooves = Vec::new();
|
||||
for g in thread_grooves(&t, radius, (cyl.start, cyl.end), ends, steep, ease)? {
|
||||
for g in thread_grooves(&t, radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||
let section = Region2 { outer: solidcraft_geom::Loop2::polygon(&g.section), holes: Vec::new() };
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?);
|
||||
}
|
||||
if let Some(taper) = taper
|
||||
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
|
||||
{
|
||||
grooves.push(envelope);
|
||||
}
|
||||
let tool = match grooves.as_slice() {
|
||||
[one] => one.clone(),
|
||||
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A thread's `form` may be `bspp` (ISO 228-1 parallel pipe thread, `G 1/2`), `bspt` (ISO 7-1
|
||||
//! taper, `R 1/2` external, `Rc 1/2` internal) or `npt` (ASME B1.20.1 taper, `1/2-14 NPT`). An
|
||||
//! older design has none of them and needs no rewrite. The last release refuses a design saved
|
||||
//! with this (the newer format number).
|
||||
@@ -48,6 +48,7 @@ pub mod persistent_name;
|
||||
pub mod physical_material;
|
||||
mod piece_names;
|
||||
pub mod pipe_thread_catalog;
|
||||
pub mod pipe_thread_sizes;
|
||||
mod points;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! The sizes of the pipe-thread forms a modelled thread cuts: ISO 7-1 (55°, taper R external and
|
||||
//! Rc internal; its gauge-plane diameters are also the parallel G sizes of ISO 228-1) and ASME
|
||||
//! B1.20.1 NPT (60°, taper). Lengths and diameters in mm; the taper is 1 in 16 on the diameter.
|
||||
//!
|
||||
//! Sources (docs/threads.md):
|
||||
//! - ISO 7-1:1978, Table 1, columns 1 to 8 and 15, as the public preview prints them:
|
||||
//! <https://cdn.standards.iteh.ai/samples/2856/96e1948d3d734f28b2563bcab2920245/ISO-7-1-1978.pdf>.
|
||||
//! - NBS Handbook H28 (1957) Part II, Table VII.2, "Basic dimensions of American Standard taper
|
||||
//! pipe threads, NPT" (a U.S. government publication):
|
||||
//! <https://nvlpubs.nist.gov/nistpubs/Legacy/hb/nbshandbook28supp1957pt2.pdf>.
|
||||
|
||||
const INCH: f64 = 25.4;
|
||||
|
||||
/// One taper pipe-thread size, by the plane its diameters are given at (the reference plane).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PipeSize {
|
||||
/// The nominal size as the standard writes it: `1/2`, `1 1/4`.
|
||||
pub nominal: &'static str,
|
||||
/// Threads per inch.
|
||||
pub tpi: f64,
|
||||
/// The major diameter at the reference plane: ISO 7-1's gauge diameter d, NPT's E1 + h.
|
||||
pub major: f64,
|
||||
/// From the small end of the external thread to the reference plane: ISO 7-1's basic gauge
|
||||
/// length, NPT's hand-tight engagement L1. The internal thread has it at its face.
|
||||
pub gauge: f64,
|
||||
/// The external thread's length: ISO 7-1's useful thread for the basic gauge length (not
|
||||
/// less than), NPT's effective thread L2.
|
||||
pub length: f64,
|
||||
}
|
||||
|
||||
impl PipeSize {
|
||||
pub fn pitch(&self) -> f64 {
|
||||
INCH / self.tpi
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 7-1:1978 Table 1: designation, threads per 25.4 mm, major (gauge) diameter d, basic gauge
|
||||
/// length, useful thread for the basic gauge length (columns 1, 2, 5, 8 and 15).
|
||||
const ISO_7_1: &[(&str, f64, f64, f64, f64)] = &[
|
||||
("1/16", 28.0, 7.723, 4.0, 6.5),
|
||||
("1/8", 28.0, 9.728, 4.0, 6.5),
|
||||
("1/4", 19.0, 13.157, 6.0, 9.7),
|
||||
("3/8", 19.0, 16.662, 6.4, 10.1),
|
||||
("1/2", 14.0, 20.955, 8.2, 13.2),
|
||||
("3/4", 14.0, 26.441, 9.5, 14.5),
|
||||
("1", 11.0, 33.249, 10.4, 16.8),
|
||||
("1 1/4", 11.0, 41.910, 12.7, 19.1),
|
||||
("1 1/2", 11.0, 47.803, 12.7, 19.1),
|
||||
("2", 11.0, 59.614, 15.9, 23.4),
|
||||
("2 1/2", 11.0, 75.184, 17.5, 26.7),
|
||||
("3", 11.0, 87.884, 20.6, 29.8),
|
||||
("4", 11.0, 113.030, 25.4, 35.8),
|
||||
("5", 11.0, 138.430, 28.6, 40.1),
|
||||
("6", 11.0, 163.830, 28.6, 40.1),
|
||||
];
|
||||
|
||||
/// H28 Table VII.2 (inches): nominal size, outside diameter of pipe D, threads per inch, pitch
|
||||
/// diameter at the beginning of the external thread E0, hand-tight engagement L1, effective
|
||||
/// thread L2 (columns 1, 2, 3, 5, 6 and 9).
|
||||
const NPT: &[(&str, f64, f64, f64, f64, f64)] = &[
|
||||
("1/16", 0.3125, 27.0, 0.27118, 0.160, 0.2611),
|
||||
("1/8", 0.405, 27.0, 0.36351, 0.1615, 0.2639),
|
||||
("1/4", 0.540, 18.0, 0.47739, 0.2278, 0.4018),
|
||||
("3/8", 0.675, 18.0, 0.61201, 0.240, 0.4078),
|
||||
("1/2", 0.840, 14.0, 0.75843, 0.320, 0.5337),
|
||||
("3/4", 1.050, 14.0, 0.96768, 0.339, 0.5457),
|
||||
("1", 1.315, 11.5, 1.21363, 0.400, 0.6828),
|
||||
("1 1/4", 1.660, 11.5, 1.55713, 0.420, 0.7068),
|
||||
("1 1/2", 1.900, 11.5, 1.79609, 0.420, 0.7235),
|
||||
("2", 2.375, 11.5, 2.26902, 0.436, 0.7565),
|
||||
("2 1/2", 2.875, 8.0, 2.71953, 0.682, 1.1375),
|
||||
("3", 3.500, 8.0, 3.34062, 0.766, 1.2000),
|
||||
("3 1/2", 4.000, 8.0, 3.83750, 0.821, 1.2500),
|
||||
("4", 4.500, 8.0, 4.33438, 0.844, 1.3000),
|
||||
("5", 5.563, 8.0, 5.39073, 0.937, 1.4063),
|
||||
("6", 6.625, 8.0, 6.44609, 0.958, 1.5125),
|
||||
("8", 8.625, 8.0, 8.43359, 1.063, 1.7125),
|
||||
("10", 10.750, 8.0, 10.54531, 1.210, 1.9250),
|
||||
("12", 12.750, 8.0, 12.53281, 1.360, 2.1250),
|
||||
];
|
||||
|
||||
/// NPT's thread height h = 0.8 p (H28 VII.2.2).
|
||||
pub const NPT_HEIGHT: f64 = 0.8;
|
||||
|
||||
/// The ISO 7-1 sizes (R, Rc; their diameters are ISO 228-1's G sizes too).
|
||||
pub fn iso_7_1() -> impl Iterator<Item = PipeSize> {
|
||||
ISO_7_1.iter().map(|&(nominal, tpi, major, gauge, length)| PipeSize { nominal, tpi, major, gauge, length })
|
||||
}
|
||||
|
||||
/// The NPT sizes, the major diameter at the hand-tight plane E1 + h (E1 = E0 + L1/16).
|
||||
pub fn npt() -> impl Iterator<Item = PipeSize> {
|
||||
NPT.iter().map(|&(nominal, _, tpi, e0, l1, l2)| {
|
||||
let e1 = e0 + l1 / 16.0;
|
||||
PipeSize { nominal, tpi, major: (e1 + NPT_HEIGHT / tpi) * INCH, gauge: l1 * INCH, length: l2 * INCH }
|
||||
})
|
||||
}
|
||||
|
||||
/// The pipe's outside diameter D an NPT size is cut on (mm).
|
||||
pub fn npt_pipe_od(nominal: &str) -> Option<f64> {
|
||||
NPT.iter().find(|r| r.0 == nominal).map(|r| r.1 * INCH)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The transcribed ISO 7-1 rows against the standard's own relations: P = 25.4/n, h =
|
||||
/// 0.640327 P, d2 = d - h, d1 = d - 2h (columns 3, 4, 6, 7, rounded to 3 decimals there).
|
||||
#[test]
|
||||
fn iso_7_1_rows_follow_their_formulas() {
|
||||
let cols: [(f64, f64, f64, f64); 15] = [
|
||||
(0.907, 0.581, 7.142, 6.561),
|
||||
(0.907, 0.581, 9.147, 8.566),
|
||||
(1.337, 0.856, 12.301, 11.445),
|
||||
(1.337, 0.856, 15.806, 14.950),
|
||||
(1.814, 1.162, 19.793, 18.631),
|
||||
(1.814, 1.162, 25.279, 24.117),
|
||||
(2.309, 1.479, 31.770, 30.291),
|
||||
(2.309, 1.479, 40.431, 38.952),
|
||||
(2.309, 1.479, 46.324, 44.845),
|
||||
(2.309, 1.479, 58.135, 56.656),
|
||||
(2.309, 1.479, 73.705, 72.226),
|
||||
(2.309, 1.479, 86.405, 84.926),
|
||||
(2.309, 1.479, 111.551, 110.072),
|
||||
(2.309, 1.479, 136.951, 135.472),
|
||||
(2.309, 1.479, 162.351, 160.872),
|
||||
];
|
||||
for (s, (p, h, d2, d1)) in iso_7_1().zip(cols) {
|
||||
assert!((s.pitch() - p).abs() < 5e-4, "{}", s.nominal);
|
||||
assert!((0.640327 * s.pitch() - h).abs() < 5e-4, "{}", s.nominal);
|
||||
assert!((s.major - h - d2).abs() < 1.5e-3 && (s.major - 2.0 * h - d1).abs() < 1.5e-3, "{}", s.nominal);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transcribed NPT rows against H28's formulas: E0 = D - (0.05 D + 1.1) p and L2 = (0.80 D
|
||||
/// + 6.8) p (VII.3.4 and VII.3.5).
|
||||
#[test]
|
||||
fn npt_rows_follow_their_formulas() {
|
||||
for &(n, d, tpi, e0, _, l2) in NPT {
|
||||
let p = 1.0 / tpi;
|
||||
assert!((d - (0.05 * d + 1.1) * p - e0).abs() < 2e-5, "{n}");
|
||||
assert!(((0.80 * d + 6.8) * p - l2).abs() < 1e-4, "{n}");
|
||||
}
|
||||
let half = npt().find(|s| s.nominal == "1/2").unwrap();
|
||||
// E1 = 0.77843 in (column 7), h = 0.8/14.
|
||||
assert!((half.major - (0.77843 + 0.8 / 14.0) * INCH).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,14 @@ pub enum ThreadForm {
|
||||
ButtressAsme,
|
||||
/// The square thread: no current standard defines it, so it is refused.
|
||||
Square,
|
||||
/// Parallel pipe thread, 55° (ISO 228-1, the Whitworth form with H = 0.960491 P): `G 1/2`.
|
||||
Bspp,
|
||||
/// Taper pipe thread, 55°, taper 1 in 16 (ISO 7-1, H = 0.960237 P): `R 1/2` external, `Rc 1/2`
|
||||
/// internal.
|
||||
Bspt,
|
||||
/// American National Standard taper pipe thread, 60°, taper 1 in 16 (ASME B1.20.1):
|
||||
/// `1/2-14 NPT`.
|
||||
Npt,
|
||||
}
|
||||
|
||||
/// Which way a thread turns.
|
||||
@@ -231,8 +239,17 @@ impl GrooveProfile {
|
||||
}
|
||||
|
||||
impl ThreadForm {
|
||||
pub const ALL: [ThreadForm; 6] =
|
||||
[ThreadForm::Metric, ThreadForm::Trapezoidal, ThreadForm::Acme, ThreadForm::Buttress, ThreadForm::ButtressAsme, ThreadForm::Square];
|
||||
pub const ALL: [ThreadForm; 9] = [
|
||||
ThreadForm::Metric,
|
||||
ThreadForm::Trapezoidal,
|
||||
ThreadForm::Acme,
|
||||
ThreadForm::Buttress,
|
||||
ThreadForm::ButtressAsme,
|
||||
ThreadForm::Square,
|
||||
ThreadForm::Bspp,
|
||||
ThreadForm::Bspt,
|
||||
ThreadForm::Npt,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -242,6 +259,9 @@ impl ThreadForm {
|
||||
ThreadForm::Buttress => "buttress",
|
||||
ThreadForm::ButtressAsme => "buttress_asme",
|
||||
ThreadForm::Square => "square",
|
||||
ThreadForm::Bspp => "bspp",
|
||||
ThreadForm::Bspt => "bspt",
|
||||
ThreadForm::Npt => "npt",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,9 +278,31 @@ impl ThreadForm {
|
||||
ThreadForm::Buttress => "DIN 513-1, DIN 513-2",
|
||||
ThreadForm::ButtressAsme => "ASME B1.9",
|
||||
ThreadForm::Square => "none",
|
||||
ThreadForm::Bspp => "ISO 228-1",
|
||||
ThreadForm::Bspt => "ISO 7-1",
|
||||
ThreadForm::Npt => "ASME B1.20.1",
|
||||
}
|
||||
}
|
||||
|
||||
/// A taper pipe thread (1 in 16 on the diameter).
|
||||
pub fn is_taper(self) -> bool {
|
||||
matches!(self, ThreadForm::Bspt | ThreadForm::Npt)
|
||||
}
|
||||
|
||||
/// The pipe sizes of a pipe-thread form (ISO 228-1's G sizes are ISO 7-1's gauge diameters).
|
||||
pub fn pipe_sizes(self) -> Vec<crate::pipe_thread_sizes::PipeSize> {
|
||||
match self {
|
||||
ThreadForm::Bspp | ThreadForm::Bspt => crate::pipe_thread_sizes::iso_7_1().collect(),
|
||||
ThreadForm::Npt => crate::pipe_thread_sizes::npt().collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipe size of major diameter `d` (at its reference plane).
|
||||
pub fn pipe_size(self, d: f64) -> Option<crate::pipe_thread_sizes::PipeSize> {
|
||||
self.pipe_sizes().into_iter().find(|s| (s.major - d).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// Why a form cannot be cut, if it cannot.
|
||||
pub fn refused(self) -> Option<&'static str> {
|
||||
(self == ThreadForm::Square).then_some(
|
||||
@@ -272,7 +314,10 @@ impl ThreadForm {
|
||||
/// size with `ACME` or `BUTT`.
|
||||
pub fn of_designation(s: &str) -> Option<ThreadForm> {
|
||||
let t = s.trim().to_ascii_uppercase();
|
||||
if t.contains("ACME") {
|
||||
let then_size = |rest: &str| rest.trim_start().starts_with(|c: char| c.is_ascii_digit());
|
||||
if t.contains("NPT") {
|
||||
Some(ThreadForm::Npt)
|
||||
} else if t.contains("ACME") {
|
||||
Some(ThreadForm::Acme)
|
||||
} else if t.contains("BUTT") {
|
||||
Some(ThreadForm::ButtressAsme)
|
||||
@@ -282,6 +327,10 @@ impl ThreadForm {
|
||||
Some(ThreadForm::Buttress)
|
||||
} else if t.starts_with('M') {
|
||||
Some(ThreadForm::Metric)
|
||||
} else if t.strip_prefix("RC").or_else(|| t.strip_prefix('R')).is_some_and(then_size) {
|
||||
Some(ThreadForm::Bspt)
|
||||
} else if t.strip_prefix('G').is_some_and(then_size) {
|
||||
Some(ThreadForm::Bspp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -296,6 +345,7 @@ impl ThreadForm {
|
||||
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(),
|
||||
ThreadForm::Bspp | ThreadForm::Bspt | ThreadForm::Npt => self.pipe_sizes().iter().map(|s| (s.major, s.pitch())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +365,13 @@ impl ThreadForm {
|
||||
{
|
||||
return Err(format!("`{s}` is a {} designation, not {}", f.name(), self.name()));
|
||||
}
|
||||
if !self.pipe_sizes().is_empty() {
|
||||
let size = self.pipe_designation(s).ok_or_else(bad)?;
|
||||
if starts > 1 {
|
||||
return Err(format!("a pipe thread has one start (`{s}`)"));
|
||||
}
|
||||
return Ok(ThreadSize { form: self, major: size.major, pitch: size.pitch(), lead: size.pitch() });
|
||||
}
|
||||
let t = s.trim().to_ascii_uppercase().replace('×', "X").replace(' ', "");
|
||||
let n = f64::from(starts.max(1));
|
||||
let (major, pitch, lead) = match self {
|
||||
@@ -375,7 +432,7 @@ impl ThreadForm {
|
||||
let p = INCH / tpi;
|
||||
(d * INCH, p, p * n)
|
||||
}
|
||||
ThreadForm::Square => return Err(bad()),
|
||||
ThreadForm::Square | ThreadForm::Bspp | ThreadForm::Bspt | ThreadForm::Npt => return Err(bad()),
|
||||
};
|
||||
if !(major.is_finite() && pitch.is_finite() && lead.is_finite() && major > 0.0 && pitch > 0.0 && pitch < major) {
|
||||
return Err(bad());
|
||||
@@ -395,9 +452,41 @@ impl ThreadForm {
|
||||
ThreadForm::Buttress => "like S20x4 or S40x14(P7)",
|
||||
ThreadForm::ButtressAsme => "like 2.5-8 BUTT",
|
||||
ThreadForm::Square => "no standard",
|
||||
ThreadForm::Bspp => "like G 1/2 (ISO 228-1, 1/16 to 6)",
|
||||
ThreadForm::Bspt => "like R 1/2 external or Rc 1/2 internal (ISO 7-1, 1/16 to 6)",
|
||||
ThreadForm::Npt => "like 1/2-14 NPT (1/16 to 12)",
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipe size a pipe-thread designation names: `G 1/2` (a class A or B after it is a
|
||||
/// tolerance, ignored), `R 1 1/4`, `Rc 1/2`, `1/2-14 NPT` or `1/2 NPT`.
|
||||
fn pipe_designation(self, s: &str) -> Option<crate::pipe_thread_sizes::PipeSize> {
|
||||
let t = s.trim().to_ascii_uppercase().replace(' ', "");
|
||||
let same = |n: &str, given: &str| n.replace(' ', "") == given;
|
||||
let find = |given: &str| self.pipe_sizes().into_iter().find(|z| same(z.nominal, given));
|
||||
match self {
|
||||
ThreadForm::Bspp => {
|
||||
let body = t.strip_prefix('G')?;
|
||||
find(body.strip_suffix(['A', 'B']).unwrap_or(body))
|
||||
}
|
||||
ThreadForm::Bspt => find(t.strip_prefix("RC").or_else(|| t.strip_prefix('R'))?),
|
||||
ThreadForm::Npt => {
|
||||
let body = t.strip_suffix("NPT")?;
|
||||
let (nominal, tpi) = match body.split_once('-') {
|
||||
Some((n, tpi)) => (n, Some(num(tpi)?)),
|
||||
None => (body, None),
|
||||
};
|
||||
find(nominal).filter(|z| tpi.is_none_or(|n| (n - z.tpi).abs() < 1e-9))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An ISO 7-1 designation on an internal thread: `Rc` (the taper internal thread), else `R`.
|
||||
pub fn pipe_internal(designation: &str) -> bool {
|
||||
designation.trim().to_ascii_uppercase().starts_with("RC")
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -429,6 +518,11 @@ impl ThreadForm {
|
||||
format!("{name}-{tpi} {}", if self == ThreadForm::Acme { "ACME" } else { "BUTT" })
|
||||
}
|
||||
ThreadForm::Square => format!("{}x{}", show(d), show(p)),
|
||||
ThreadForm::Bspp | ThreadForm::Bspt | ThreadForm::Npt => match self.pipe_size(d) {
|
||||
Some(z) if self == ThreadForm::Npt => format!("{}-{} NPT", z.nominal, show(z.tpi)),
|
||||
Some(z) => format!("{} {}", if self == ThreadForm::Bspp { "G" } else { "R" }, z.nominal),
|
||||
None => format!("{}x{}", show(d), show(p)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +600,20 @@ impl ThreadForm {
|
||||
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)
|
||||
}
|
||||
ThreadForm::Bspp | ThreadForm::Bspt | ThreadForm::Npt => {
|
||||
// ISO 228-1 / ISO 7-1: 55°, h = 0.640327 P of the triangle H = 0.960491 P
|
||||
// (parallel) or 0.960237 P (taper), rounded equally at crest and root (r =
|
||||
// 0.137329 P / 0.137278 P), modelled as flats where the rounds would be. ASME
|
||||
// B1.20.1: 60°, h = 0.8 P of H = 0.866025 P, truncated equally at crest and root
|
||||
// (the minimum, 0.033 P). The flanks' bisector is square to the axis.
|
||||
let (half_angle, big_h, h) = match self {
|
||||
ThreadForm::Bspp => (27.5, 0.960491 * p, 0.640327 * p),
|
||||
ThreadForm::Bspt => (27.5, 0.960237 * p, 0.640327 * p),
|
||||
_ => (30.0, 0.866025 * p, crate::pipe_thread_sizes::NPT_HEIGHT * p),
|
||||
};
|
||||
let flat = (big_h - h) * deg(half_angle).tan();
|
||||
(half(half_angle), flat, h, d / 2.0 - h, d / 2.0 - h, 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).
|
||||
@@ -546,6 +654,31 @@ fn show(v: f64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Pipe-thread designations read to their table's size and write back as the standards write
|
||||
/// them; a size or a threads-per-inch the table lacks is refused.
|
||||
#[test]
|
||||
fn pipe_designations_read_and_write_back() {
|
||||
for (s, form, major, tpi, written) in [
|
||||
("G 1/2", ThreadForm::Bspp, 20.955, 14.0, "G 1/2"),
|
||||
("G1/2A", ThreadForm::Bspp, 20.955, 14.0, "G 1/2"),
|
||||
("R 1 1/4", ThreadForm::Bspt, 41.910, 11.0, "R 1 1/4"),
|
||||
("Rc1/2", ThreadForm::Bspt, 20.955, 14.0, "R 1/2"),
|
||||
("1/2-14 NPT", ThreadForm::Npt, (0.77843 + 0.8 / 14.0) * INCH, 14.0, "1/2-14 NPT"),
|
||||
("1 NPT", ThreadForm::Npt, (1.21363 + 0.4 / 16.0 + 0.8 / 11.5) * INCH, 11.5, "1-11.5 NPT"),
|
||||
] {
|
||||
assert_eq!(ThreadForm::of_designation(s), Some(form), "{s}");
|
||||
let z = form.parse(s, 1).unwrap();
|
||||
assert!((z.major - major).abs() < 1e-9 && (z.pitch - INCH / tpi).abs() < 1e-12, "{s}");
|
||||
assert_eq!(form.format(z.major, z.pitch, z.lead), written);
|
||||
}
|
||||
for bad in ["G 7/8", "R 1/2x", "1/2-18 NPT", "Rp 1/2"] {
|
||||
let parsed = ThreadForm::of_designation(bad).map(|f| f.parse(bad, 1));
|
||||
assert!(!matches!(parsed, Some(Ok(_))), "{bad}");
|
||||
}
|
||||
assert!(ThreadForm::Npt.parse("1/2 NPT", 2).is_err());
|
||||
assert!(ThreadForm::Bspt.is_taper() && ThreadForm::Npt.is_taper() && !ThreadForm::Bspp.is_taper());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designations_read_and_write_back() {
|
||||
for (form, s, d, p, l) in [
|
||||
|
||||
@@ -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; 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: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_in?, ease_out?: expr|standard|short (modelled threads: the groove fades out over this length, its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; ease_in at the end the thread starts from (`from`, or the start of its extent), ease_out at the other; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); 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 refused with the size to make it, never turned down"),
|
||||
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|bspt|npt (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); bspp \"G 1/2\" (ISO 228-1, 55° parallel); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (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: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_in?, ease_out?: expr|standard|short (modelled threads: the groove fades out over this length, its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; ease_in at the end the thread starts from (`from`, or the start of its extent), ease_out at the other; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); 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 refused with the size to make it, never turned down"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -2916,8 +2916,9 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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());
|
||||
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)")))?,
|
||||
Some(f) => ThreadForm::from_name(f).ok_or_else(|| {
|
||||
bad(cmd, format!("unknown thread form `{f}` (metric, trapezoidal, acme, buttress, buttress_asme, bspp, bspt, npt or square)"))
|
||||
})?,
|
||||
None => designation.as_deref().and_then(ThreadForm::of_designation).unwrap_or_default(),
|
||||
};
|
||||
if let Some(why) = form.refused() {
|
||||
|
||||
@@ -1049,3 +1049,130 @@ fn a_modelled_threads_flank_joins_are_not_drawn() {
|
||||
// SMLib's flank pieces meet with normals about 1.5e-6 degrees apart (measured).
|
||||
assert!(flank_joins >= 32 && worst < 1e-4, "{flank_joins} flank joins, the worst {worst} deg apart");
|
||||
}
|
||||
|
||||
/// A pipe thread's profile for [`pipe_material`]: (flank half angle, thread height h, flat at crest
|
||||
/// and root), each per the pitch: ISO 228-1/7-1 55° h 0.640327 P of H 0.960491 P (parallel) or
|
||||
/// 0.960237 P (taper), ASME B1.20.1 60° h 0.8 P of H 0.866025 P.
|
||||
fn pipe_profile(form: &str) -> (f64, f64, f64) {
|
||||
let (a, big_h, h) = match form {
|
||||
"bspp" => (27.5f64, 0.960491, 0.640327),
|
||||
"bspt" => (27.5, 0.960237, 0.640327),
|
||||
_ => (30.0, 0.866025, 0.8),
|
||||
};
|
||||
(a.to_radians(), h, (big_h - h) * a.to_radians().tan())
|
||||
}
|
||||
|
||||
/// The material a pipe thread leaves, over z0..z1, as 2π∫∫ r m dr dz with m the material
|
||||
/// fraction per pitch at height y over the external thread's root, 1 - (P - flat - 2 y tan a)/P
|
||||
/// (the tooth's width over the pitch), its major radius `crest(z)` (a taper's grows 1/32 per mm)
|
||||
/// and the stock between `inner` and `outer`. External: full material under the root, the
|
||||
/// envelope's cone taking all above the crest. Internal: the nut's teeth, crest at crest(z) - h,
|
||||
/// nothing inside that (the tap's minor cone).
|
||||
fn pipe_material(form: &str, p: f64, internal: bool, (z0, z1): (f64, f64), inner: f64, outer: f64, crest: impl Fn(f64) -> f64) -> f64 {
|
||||
let (a, h, flat) = pipe_profile(form);
|
||||
let (h, flat) = (h * p, flat * p);
|
||||
let tooth = |y: f64| ((p - flat) * p.recip() - 2.0 * y * a.tan() / p).clamp(0.0, 1.0);
|
||||
let (nz, nr) = (4000, 400);
|
||||
let mut v = 0.0;
|
||||
for i in 0..nz {
|
||||
let z = z0 + (z1 - z0) * (i as f64 + 0.5) / nz as f64;
|
||||
let rc = crest(z);
|
||||
let (root, top) = if internal { (rc, rc - h) } else { (rc - h, rc) };
|
||||
let mut ring = 0.0;
|
||||
// Exactly the full annuli, the band in fine steps.
|
||||
let (b0, b1) = (root.min(top).clamp(inner, outer), root.max(top).clamp(inner, outer));
|
||||
if internal {
|
||||
ring += (outer * outer - b1 * b1) / 2.0;
|
||||
} else {
|
||||
ring += (b0 * b0 - inner * inner) / 2.0;
|
||||
}
|
||||
for j in 0..nr {
|
||||
let r = b0 + (b1 - b0) * (j as f64 + 0.5) / nr as f64;
|
||||
let y = if internal { root - r } else { r - root };
|
||||
ring += r * tooth(y) * (b1 - b0) / nr as f64;
|
||||
}
|
||||
v += 2.0 * PI * ring * (z1 - z0) / nz as f64;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// ISO 7-1 and ASME B1.20.1 taper threads, ISO 228-1 parallel: an R 1/2 and a 1/2-14 NPT on pipe
|
||||
/// stubs of their pipes' outside diameters (21.3 mm, 0.840 in), an Rc 1/2 through a plate and a
|
||||
/// G 1/2, each cut from the top across the whole face (a free end past each end, so every
|
||||
/// meridian half-plane averages the same profile). The taper puts the external thread's gauge
|
||||
/// diameter its gauge length in from the small end (R 1/2: 20.955 at 8.2 mm; NPT: E1 + h at L1)
|
||||
/// and the internal one's at its face.
|
||||
#[test]
|
||||
fn pipe_threads_cut_their_standard_profiles_and_tapers() {
|
||||
let inch = 25.4;
|
||||
// (designation, form, internal, stock radius, crest radius at z for a 12 mm stock threaded from z = 12)
|
||||
let r_half: f64 = 20.955 / 2.0 - 8.2 / 32.0;
|
||||
let npt_half: f64 = ((0.75843 + 0.32 / 16.0) + 0.8 / 14.0) * inch / 2.0 - 0.32 * inch / 32.0;
|
||||
let cases: [(&str, &str, bool, f64, Box<dyn Fn(f64) -> f64>); 4] = [
|
||||
("R 1/2", "bspt", false, 10.65, Box::new(move |z| r_half + (12.0 - z) / 32.0)),
|
||||
("1/2-14 NPT", "npt", false, 0.42 * inch, Box::new(move |z| npt_half + (12.0 - z) / 32.0)),
|
||||
("G 1/2", "bspp", false, 20.955 / 2.0, Box::new(|_| 20.955 / 2.0)),
|
||||
("Rc 1/2", "bspt", true, 9.2, Box::new(|z| 20.955 / 2.0 - (12.0 - z) / 32.0)),
|
||||
];
|
||||
for (designation, form, internal, rs, crest) in cases {
|
||||
let stock = || {
|
||||
let mut s = Session::default();
|
||||
if internal {
|
||||
run(&mut s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": 12, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -1], "radius": rs, "height": 14, "body_name": "Bore"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Part", "tools": ["Bore"], "operation": "cut"}));
|
||||
} else {
|
||||
run(&mut s, "solid.cylinder", json!({"radius": rs, "height": 12, "body_name": "Part"}));
|
||||
}
|
||||
s
|
||||
};
|
||||
let face = [rs, 0.0, 6.0];
|
||||
let (mut s, mut cosmetic) = (stock(), stock());
|
||||
let out = run(&mut cosmetic, "solid.thread", json!({"face": face, "designation": designation, "from": [rs, 0, 12], "length": 12}));
|
||||
assert_eq!(out["thread"]["designation"], json!(designation));
|
||||
run(&mut s, "solid.thread", json!({"face": face, "designation": designation, "modeled": true, "from": [rs, 0, 12], "length": 12}));
|
||||
let st = s.model.state();
|
||||
assert!(st.body("Part").unwrap().body.validity().is_empty(), "{designation}");
|
||||
let p = inch / 14.0;
|
||||
let got = exact_volume(&s, "Part");
|
||||
let (want, stock) = if internal {
|
||||
let plate = 40.0 * 40.0 * 12.0;
|
||||
let hole = PI * rs * rs * 12.0;
|
||||
(plate - hole - (PI * (30.0 * 30.0 - rs * rs) * 12.0 - pipe_material(form, p, true, (0.0, 12.0), rs, 30.0, &crest)), plate - hole)
|
||||
} else {
|
||||
(pipe_material(form, p, false, (0.0, 12.0), 0.0, rs, &crest), PI * rs * rs * 12.0)
|
||||
};
|
||||
// A parallel thread's meridians are alike exactly. A taper's section moves off the axis
|
||||
// as it turns, so the meridians differ; the averaged profile met the cut within 0.12 % of
|
||||
// the removed volume here (R 1/2 0.06 %, NPT 0.12 %).
|
||||
let tol = if form == "bspp" { 1e-5 } else { 2e-3 };
|
||||
assert!((got - want).abs() < (stock - want).abs() * tol, "{designation}: {got} vs {want} (stock {stock})");
|
||||
}
|
||||
}
|
||||
|
||||
/// A taper pipe thread's rules: it starts at a free end (not at a shoulder), ISO 7-1 names the
|
||||
/// internal taper Rc and the external R, a pipe thread has one start and turns one way, and a
|
||||
/// chamfered tip is not cut on it yet.
|
||||
#[test]
|
||||
fn a_taper_pipe_thread_starts_at_a_free_end_and_is_named_for_its_side() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 16, "height": 10, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 10], "radius": 10.65, "height": 25, "operation": "join", "targets": ["Part"]}));
|
||||
let err = |s: &mut Session, p: Value| s.execute("solid.thread", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let e = err(&mut s, json!({"face": [10.65, 0, 20], "designation": "R 1/2", "modeled": true, "from": [10.65, 0, 10]}));
|
||||
assert!(e.contains("starts at a free end"), "{e}");
|
||||
let e = err(&mut s, json!({"face": [10.65, 0, 20], "designation": "Rc 1/2", "modeled": true, "from": [10.65, 0, 35]}));
|
||||
assert!(e.contains("an external taper thread is R"), "{e}");
|
||||
let e = err(&mut s, json!({"face": [10.65, 0, 20], "designation": "R 1/2", "starts": 2, "modeled": true}));
|
||||
assert!(e.contains("one start"), "{e}");
|
||||
let e = err(&mut s, json!({"face": [10.65, 0, 20], "designation": "R 1/2", "hand": "both", "modeled": true, "from": [10.65, 0, 35]}));
|
||||
assert!(e.contains("turns one way"), "{e}");
|
||||
let e = err(&mut s, json!({"face": [10.65, 0, 20], "designation": "R 1/2", "tip": "both", "modeled": true, "from": [10.65, 0, 35]}));
|
||||
assert!(e.contains("taper pipe thread"), "{e}");
|
||||
// From the free end it runs ISO 7-1's useful thread, 13.2 mm for R 1/2.
|
||||
let out = run(&mut s, "solid.thread", json!({"face": [10.65, 0, 20], "designation": "R 1/2", "from": [10.65, 0, 35]}));
|
||||
let t = &out["thread"];
|
||||
assert!((t["end"].as_f64().unwrap() - t["start"].as_f64().unwrap() - 13.2).abs() < 1e-9, "{t}");
|
||||
run(&mut s, "solid.thread", json!({"face": [10.65, 0, 20], "designation": "R 1/2", "modeled": true, "from": [10.65, 0, 35]}));
|
||||
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
|
||||
}
|
||||
|
||||
@@ -23,6 +23,31 @@ modelled: a modelled thread is the maximum-material design profile below.
|
||||
| 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) |
|
||||
|
||||
| Parallel pipe | `bspp` | `G 1/2` | 27.5° / 27.5° | h = 0.640327 P of H = 0.960491 P | (H − h) tan 27.5° = 0.1667 P, both | d − 2h | d − 2h, d |
|
||||
| Taper pipe (ISO 7-1) | `bspt` | `R 1/2`, `Rc 1/2` | 27.5° / 27.5° | h = 0.640327 P of H = 0.960237 P | (H − h) tan 27.5°, both | d − 2h at the gauge plane | d − 2h, d at the gauge plane |
|
||||
| NPT | `npt` | `1/2-14 NPT` | 30° / 30° | h = 0.8 P of H = 0.866025 P | (H − h) tan 30° = 0.038 P, both | E − h at the plane of E | E − h, E + h |
|
||||
|
||||
The pipe threads' crest and root rounds (ISO 228-1/7-1 r = 0.137329 P, 0.137278 P taper) are
|
||||
modelled as flats at the basic height, as the other forms' radii; NPT takes the minimum truncation
|
||||
0.033 P at both. The flanks' bisector is square to the axis in all three.
|
||||
|
||||
**Taper pipe threads** (`bspt`, `npt`): the diameters change 1 in 16 along the axis. The thread
|
||||
starts at a free end, the pipe's end or the hole's mouth (`from`), with the diameter its standard
|
||||
puts there: an external thread its major diameter at the gauge (reference) plane its gauge length
|
||||
in from the small end (ISO 7-1: the basic gauge length, R 1/2 20.955 mm at 8.2 mm; NPT: E1 + h at
|
||||
the hand-tight length L1, E1 = E0 + L1/16), an internal one the reference diameter at its face
|
||||
(ISO 7-1's gauge plane; NPT's E1 at the large end). The groove is SMLib's screw sweep with its
|
||||
section moved 1/32 of the axial travel off the axis per unit length (the radial law that also
|
||||
makes runouts), its walls within the sweep's tolerance of the true surfaces; its section reaches
|
||||
just past the crest, and the material beyond the major cone (external) or inside the minor cone
|
||||
(internal) is cut with it, as the die or tap cuts the crests. Past the major cone the crests
|
||||
vanish into the pipe's surface (ISO 7-1's incomplete thread). An external taper thread runs its
|
||||
standard length unless `length` or `up_to` says otherwise (ISO 7-1's useful thread for the basic
|
||||
gauge length, NPT's effective thread L2); an internal one the face. A pipe thread has one start and
|
||||
turns one way; a chamfered tip is not cut on a taper thread yet. ISO 228-1's G sizes are ISO 7-1's
|
||||
gauge-plane diameters (1/16 to 6); ISO 228-1's extra sizes (5/8, 7/8, …) are not tabulated yet, nor
|
||||
is ISO 7-1's parallel internal Rp.
|
||||
|
||||
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.
|
||||
|
||||
@@ -112,5 +137,15 @@ the hole's countersink instead.
|
||||
<https://cdn.standards.iteh.ai/samples/55928/38ddde12863b4c2db35dc929fd502a4d/ISO-4753-2011.pdf>):
|
||||
the end's diameter "Maximum diameter at the minor thread diameter", the angle ≤ 50°, the
|
||||
incomplete thread u ≤ 2 P.
|
||||
- ISO 7-1:1978 (taper and parallel 55° profiles: H = 0.960237 P / 0.960491 P, h = 0.640327 P,
|
||||
r = 0.137278 P / 0.137329 P, figures 1 and 2; Table 1, columns 1 to 8 and 15: threads per 25.4
|
||||
mm, major diameter at the gauge plane, basic gauge length, useful thread), from the public
|
||||
preview <https://cdn.standards.iteh.ai/samples/2856/96e1948d3d734f28b2563bcab2920245/ISO-7-1-1978.pdf>;
|
||||
the same diameters as G sizes in <https://en.wikipedia.org/wiki/British_Standard_Pipe> and the
|
||||
Yamawa YEU23 tables `pipe_thread_catalog.rs` cites.
|
||||
- ASME B1.20.1 (NPT) as NBS Handbook H28 (1957) Part II, section VII publishes it: 60°, H =
|
||||
0.866025 p, h = 0.800 p, truncation at least 0.033 p, taper 1 in 16, E0 = D − (0.05 D + 1.1) p,
|
||||
E1 = E0 + 0.0625 L1, L2 = (0.80 D + 6.8) p, and Table VII.2 (D, n, E0, L1, L2 for 1/16 to 12):
|
||||
<https://nvlpubs.nist.gov/nistpubs/Legacy/hb/nbshandbook28supp1957pt2.pdf>.
|
||||
- ISO 724 (external minor diameter d3 = d1 − H/6 = d − 1.226869 P, M10x1.5: 8.16), as given at
|
||||
<https://www.valvias.com/miscellanea-metric-thread-din-13.php>.
|
||||
|
||||
Reference in New Issue
Block a user