Thread: the pipe forms in the dialog, ISO 7-1's Rp, a chamfered tip on a taper thread

The thread dialog offers the pipe threads: G (ISO 228-1), Rp (ISO 7-1), R and Rc (ISO 7-1) and
NPT (ASME B1.20.1). Their sizes are listed by designation without a pitch, one start and a hand
of right or left; a size picked for a hole the dialog sized Rc stays Rc.

Rp, ISO 7-1's parallel internal thread an R screws into, is a form of its own (`rp`): the
parallel profile (H = 0.960491 P) at ISO 7-1's gauge diameters, refused on a shaft. Its cut
meets the analytic material of its profile within 1e-5, as G does.

A taper thread takes a chamfered tip: each end's cone is measured from the major diameter the
taper has at that end. Pipe threads are not ISO metric, so they give chamfer_depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-27 07:57:20 -04:00
committed by Irakli Kourtis
parent 3865982820
commit 80e47130b9
9 changed files with 222 additions and 43 deletions
+44
View File
@@ -0,0 +1,44 @@
{
"en": {
"thread.form_bspp": "Pipe G (ISO 228-1)",
"thread.form_bspt": "Pipe taper R, Rc (ISO 7-1)",
"thread.form_npt": "Pipe taper NPT (ASME B1.20.1)",
"thread.form_rp": "Pipe Rp (ISO 7-1)"
},
"ar": {
"thread.form_bspp": "سن أنابيب G (ISO 228-1)",
"thread.form_bspt": "سن أنابيب مخروطي R، Rc (ISO 7-1)",
"thread.form_npt": "سن أنابيب مخروطي NPT (ASME B1.20.1)",
"thread.form_rp": "سن أنابيب داخلي متوازٍ Rp (ISO 7-1)"
},
"he": {
"thread.form_bspp": "תבריג צנרת G (ISO 228-1)",
"thread.form_bspt": "תבריג צנרת קוני R, Rc (ISO 7-1)",
"thread.form_npt": "תבריג צנרת קוני NPT (ASME B1.20.1)",
"thread.form_rp": "תבריג צנרת פנימי מקביל Rp (ISO 7-1)"
},
"ja": {
"thread.form_bspp": "管用平行ねじ G(ISO 228-1)",
"thread.form_bspt": "管用テーパねじ R、Rc(ISO 7-1)",
"thread.form_npt": "管用テーパねじ NPT(ASME B1.20.1)",
"thread.form_rp": "管用平行めねじ Rp(ISO 7-1)"
},
"zh": {
"thread.form_bspp": "管螺纹 G(ISO 228-1)",
"thread.form_bspt": "圆锥管螺纹 R、Rc(ISO 7-1)",
"thread.form_npt": "圆锥管螺纹 NPT(ASME B1.20.1)",
"thread.form_rp": "圆柱内管螺纹 Rp(ISO 7-1)"
},
"de": {
"thread.form_bspp": "Rohrgewinde G (ISO 228-1)",
"thread.form_bspt": "Kegeliges Rohrgewinde R, Rc (ISO 7-1)",
"thread.form_npt": "Kegeliges Rohrgewinde NPT (ASME B1.20.1)",
"thread.form_rp": "Zylindrisches Innenrohrgewinde Rp (ISO 7-1)"
},
"ko": {
"thread.form_bspp": "관용 평행나사 G (ISO 228-1)",
"thread.form_bspt": "관용 테이퍼나사 R, Rc (ISO 7-1)",
"thread.form_npt": "관용 테이퍼나사 NPT (ASME B1.20.1)",
"thread.form_rp": "관용 평행 암나사 Rp (ISO 7-1)"
}
}
+23 -10
View File
@@ -216,6 +216,12 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
if ask.hand == ThreadHand::Both {
return Err(DocError::Invalid("a pipe thread turns one way: right or left".into()));
}
// ISO 7-1's Rp is the parallel internal thread an R screws into; there is no external Rp.
if form == ThreadForm::Rp && !cyl.internal {
return Err(DocError::Invalid(format!(
"`{designation}` on this shaft: Rp is an internal thread (ISO 7-1); an external pipe thread is R (taper, ISO 7-1) or G (ISO 228-1)"
)));
}
// ISO 7-1 names the internal taper thread Rc and the external one R.
if form == ThreadForm::Bspt {
if let Some(given) = ask.designation
@@ -715,9 +721,6 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
let ask = ThreadAsk { ease_out: 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(());
@@ -758,7 +761,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if *tip != ThreadTip::None {
let angle = chamfer_angle.as_deref().map(|e| val(vals, e, Kind::Angle)).transpose()?;
let depth = chamfer_depth.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?;
for cone in tip_cones(&t, radius, ends, (*tip, from_side), angle, depth)? {
for cone in tip_cones(&t, radius, ends, (*tip, from_side), angle, depth, taper)? {
let faces = cone.face_count();
let cut = kernel::boolean(&nb.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
@@ -780,7 +783,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
/// that bound, d3 = d - 1.226869 P (ISO 724, the rounded root of the design profile). It lies
/// below the modelled basic root d1, so the cone crosses the root cylinder and leaves a whole
/// ring on the end; one ending on the root itself would pinch to nothing wherever the groove
/// reaches the end. ISO 4753 is for ISO metric threads: other forms give `depth`.
/// reaches the end. ISO 4753 is for ISO metric threads: other forms give `depth`. A taper
/// thread's `taper` (its major diameter where it starts, the end it starts from) puts each end's
/// major diameter where the taper has it there, and `depth` is measured from that.
fn tip_cones(
t: &ThreadInfo,
radius: f64,
@@ -788,6 +793,7 @@ fn tip_cones(
(tip, from): (ThreadTip, usize),
angle: Option<f64>,
depth: Option<f64>,
taper: Option<(f64, usize)>,
) -> Result<Vec<Body>> {
if t.internal {
return Err(DocError::Invalid("a chamfered tip is for an external thread (ISO 4753); countersink the hole's entry instead".into()));
@@ -797,7 +803,12 @@ fn tip_cones(
if !(a > 1f64.to_radians() && a < 89f64.to_radians()) {
return Err(DocError::Invalid("the chamfer's angle to the axis must be between 1° and 89°".into()));
}
let r_top = radius.max(t.major_diameter / 2.0);
// The major diameter at each end of the axis: a taper's grows 1 in 16 from where it starts.
let major = |side: usize| match taper {
Some((d0, start)) if side == start => d0,
Some((d0, _)) => d0 + (t.end - t.start) / 16.0,
None => t.major_diameter,
};
let depth = match (depth, t.form) {
(Some(d), _) => d,
// ISO 724: d3 = d1 - H/6 = d - 2 (17/24) H.
@@ -810,14 +821,14 @@ fn tip_cones(
)));
}
};
let r_f = t.major_diameter / 2.0 - depth;
if !(depth > 0.0 && r_f > 1e-3) {
return Err(DocError::Invalid(format!("the chamfer's depth must be between 0 and {} mm", show_mm(t.major_diameter / 2.0))));
let smallest = major(0).min(major(1)) / 2.0;
if !(depth > 0.0 && smallest - depth > 1e-3) {
return Err(DocError::Invalid(format!("the chamfer's depth must be between 0 and {} mm", show_mm(smallest))));
}
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 (r_out, slope) = (r_top + 0.1 * t.pitch, a.tan());
let slope = a.tan();
// Past the end only the radii the groove's overrun already reaches (r >= d_f / 2), so
// nothing past a free end beyond the thread is touched.
let e = 0.5 * t.pitch;
@@ -830,6 +841,8 @@ fn tip_cones(
if !free(side) || (tip == ThreadTip::Start && side != from) {
continue;
}
let r_f = major(side) / 2.0 - depth;
let r_out = radius.max(major(side) / 2.0) + 0.1 * t.pitch;
// In the plane: x radial, y outward along the axis from the end.
let plane = Plane { origin: t.axis_point + k * at, x: u, y: out_dir };
let pts = [Vec2::new(r_f, 0.0), Vec2::new(r_f, e), Vec2::new(r_out, e), Vec2::new(r_out, -(r_out - r_f) / slope)];
@@ -1,4 +1,5 @@
//! A thread's `form` may be `bspp` (ISO 228-1 parallel pipe thread, `G 1/2`), `bspt` (ISO 7-1
//! A thread's `form` may be `bspp` (ISO 228-1 parallel pipe thread, `G 1/2`), `rp` (ISO 7-1's
//! parallel internal thread, `Rp 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).
+31 -11
View File
@@ -28,6 +28,9 @@ pub enum ThreadForm {
Square,
/// Parallel pipe thread, 55° (ISO 228-1, the Whitworth form with H = 0.960491 P): `G 1/2`.
Bspp,
/// Parallel internal pipe thread, 55° (ISO 7-1's Rp, the parallel form with H = 0.960491 P at
/// the gauge diameters): `Rp 1/2`, which a taper external `R` thread screws into.
Rp,
/// Taper pipe thread, 55°, taper 1 in 16 (ISO 7-1, H = 0.960237 P): `R 1/2` external, `Rc 1/2`
/// internal.
Bspt,
@@ -239,7 +242,7 @@ impl GrooveProfile {
}
impl ThreadForm {
pub const ALL: [ThreadForm; 9] = [
pub const ALL: [ThreadForm; 10] = [
ThreadForm::Metric,
ThreadForm::Trapezoidal,
ThreadForm::Acme,
@@ -247,6 +250,7 @@ impl ThreadForm {
ThreadForm::ButtressAsme,
ThreadForm::Square,
ThreadForm::Bspp,
ThreadForm::Rp,
ThreadForm::Bspt,
ThreadForm::Npt,
];
@@ -260,6 +264,7 @@ impl ThreadForm {
ThreadForm::ButtressAsme => "buttress_asme",
ThreadForm::Square => "square",
ThreadForm::Bspp => "bspp",
ThreadForm::Rp => "rp",
ThreadForm::Bspt => "bspt",
ThreadForm::Npt => "npt",
}
@@ -279,7 +284,7 @@ impl ThreadForm {
ThreadForm::ButtressAsme => "ASME B1.9",
ThreadForm::Square => "none",
ThreadForm::Bspp => "ISO 228-1",
ThreadForm::Bspt => "ISO 7-1",
ThreadForm::Rp | ThreadForm::Bspt => "ISO 7-1",
ThreadForm::Npt => "ASME B1.20.1",
}
}
@@ -292,7 +297,7 @@ impl ThreadForm {
/// 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::Bspp | ThreadForm::Rp | ThreadForm::Bspt => crate::pipe_thread_sizes::iso_7_1().collect(),
ThreadForm::Npt => crate::pipe_thread_sizes::npt().collect(),
_ => Vec::new(),
}
@@ -327,6 +332,8 @@ impl ThreadForm {
Some(ThreadForm::Buttress)
} else if t.starts_with('M') {
Some(ThreadForm::Metric)
} else if t.strip_prefix("RP").is_some_and(then_size) {
Some(ThreadForm::Rp)
} 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) {
@@ -345,7 +352,9 @@ 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(),
ThreadForm::Bspp | ThreadForm::Rp | ThreadForm::Bspt | ThreadForm::Npt => {
self.pipe_sizes().iter().map(|s| (s.major, s.pitch())).collect()
}
}
}
@@ -432,7 +441,7 @@ impl ThreadForm {
let p = INCH / tpi;
(d * INCH, p, p * n)
}
ThreadForm::Square | ThreadForm::Bspp | ThreadForm::Bspt | ThreadForm::Npt => return Err(bad()),
ThreadForm::Square | ThreadForm::Bspp | ThreadForm::Rp | 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());
@@ -453,13 +462,14 @@ impl ThreadForm {
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::Rp => "like Rp 1/2, internal (ISO 7-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`.
/// tolerance, ignored), `Rp 1/2`, `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;
@@ -469,6 +479,7 @@ impl ThreadForm {
let body = t.strip_prefix('G')?;
find(body.strip_suffix(['A', 'B']).unwrap_or(body))
}
ThreadForm::Rp => find(t.strip_prefix("RP")?),
ThreadForm::Bspt => find(t.strip_prefix("RC").or_else(|| t.strip_prefix('R'))?),
ThreadForm::Npt => {
let body = t.strip_suffix("NPT")?;
@@ -518,9 +529,16 @@ 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) {
ThreadForm::Bspp | ThreadForm::Rp | 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),
Some(z) => {
let pre = match self {
ThreadForm::Bspp => "G",
ThreadForm::Rp => "Rp",
_ => "R",
};
format!("{pre} {}", z.nominal)
}
None => format!("{}x{}", show(d), show(p)),
},
}
@@ -600,14 +618,14 @@ 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 => {
ThreadForm::Bspp | ThreadForm::Rp | 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::Bspp | ThreadForm::Rp => (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),
};
@@ -661,6 +679,8 @@ mod tests {
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"),
("Rp 1/2", ThreadForm::Rp, 20.955, 14.0, "Rp 1/2"),
("RP3/4", ThreadForm::Rp, 26.441, 14.0, "Rp 3/4"),
("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"),
@@ -671,7 +691,7 @@ mod tests {
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"] {
for bad in ["G 7/8", "R 1/2x", "1/2-18 NPT", "Rp 7/8"] {
let parsed = ThreadForm::of_designation(bad).map(|f| f.parse(bad, 1));
assert!(!matches!(parsed, Some(Ok(_))), "{bad}");
}
+2 -2
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; 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_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); 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|rp|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); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); 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_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); 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")
@@ -2917,7 +2917,7 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
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, bspp, bspt, npt or square)"))
bad(cmd, format!("unknown thread form `{f}` (metric, trapezoidal, acme, buttress, buttress_asme, bspp, rp, bspt, npt or square)"))
})?,
None => designation.as_deref().and_then(ThreadForm::of_designation).unwrap_or_default(),
};
+33 -11
View File
@@ -1069,7 +1069,7 @@ fn a_modelled_threads_flank_joins_are_not_drawn() {
/// 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),
"bspp" | "rp" => (27.5f64, 0.960491, 0.640327),
"bspt" => (27.5, 0.960237, 0.640327),
_ => (30.0, 0.866025, 0.8),
};
@@ -1122,11 +1122,14 @@ fn pipe_threads_cut_their_standard_profiles_and_tapers() {
// (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] = [
// ISO 7-1's Rp, parallel and internal, in a hole drilled to its minor diameter.
let rp_minor = 20.955 / 2.0 - 0.640327 * inch / 14.0;
let cases: [(&str, &str, bool, f64, Box<dyn Fn(f64) -> f64>); 5] = [
("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)),
("Rp 1/2", "rp", true, rp_minor + 1e-4, Box::new(|_| 20.955 / 2.0)),
];
for (designation, form, internal, rs, crest) in cases {
let stock = || {
@@ -1159,19 +1162,23 @@ fn pipe_threads_cut_their_standard_profiles_and_tapers() {
// 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 };
let tol = if matches!(form, "bspp" | "rp") { 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.
/// internal taper Rc and the external R (and Rp is internal only), a pipe thread has one start and
/// turns one way, and its chamfered tip is cut from the small end's major diameter.
#[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 part = || {
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"]}));
s
};
let mut s = 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}");
@@ -1181,12 +1188,27 @@ fn a_taper_pipe_thread_starts_at_a_free_end_and_is_named_for_its_side() {
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}");
let e = err(&mut s, json!({"face": [10.65, 0, 20], "designation": "Rp 1/2", "modeled": true, "from": [10.65, 0, 35]}));
assert!(e.contains("Rp is an internal thread"), "{e}");
// ISO 4753's chamfered end is metric: a pipe thread gives its depth.
let e = err(&mut s, json!({"face": [10.65, 0, 20], "designation": "R 1/2", "tip": "start", "modeled": true, "from": [10.65, 0, 35]}));
assert!(e.contains("chamfer depth"), "{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]}));
let mut plain = part();
run(&mut plain, "solid.thread", json!({"face": [10.65, 0, 20], "designation": "R 1/2", "modeled": true, "from": [10.65, 0, 35]}));
assert!(plain.model.state().body("Part").unwrap().body.validity().is_empty());
// The chamfered tip of a taper thread: the cone reaches the small end's major diameter less
// the depth, and cuts the first teeth.
let p = 25.4 / 14.0;
run(
&mut s,
"solid.thread",
json!({"face": [10.65, 0, 20], "designation": "R 1/2", "modeled": true, "from": [10.65, 0, 35], "tip": "start", "chamfer_depth": 0.640327 * p}),
);
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
let (chamfered, threaded) = (exact_volume(&s, "Part"), exact_volume(&plain, "Part"));
assert!(chamfered < threaded - 1e-3, "the tip cuts the first teeth: {chamfered} vs {threaded}");
}
+31 -5
View File
@@ -164,9 +164,13 @@ const FORMS: &[(&str, ThreadForm)] = &[
("Acme (ASME B1.5)", ThreadForm::Acme),
("Buttress (DIN 513)", ThreadForm::Buttress),
("Buttress (ASME B1.9)", ThreadForm::ButtressAsme),
("thread.form_bspp", ThreadForm::Bspp),
("thread.form_rp", ThreadForm::Rp),
("thread.form_bspt", ThreadForm::Bspt),
("thread.form_npt", ThreadForm::Npt),
];
const HANDS: &[(&str, ThreadHand)] = &[("Right", ThreadHand::Right), ("Left", ThreadHand::Left), ("Both", ThreadHand::Both)];
/// A tapped hole's hands: one groove, so never both.
/// A tapped hole's and a pipe thread's hands: one groove, so never both.
const HOLE_HANDS: &[(&str, ThreadHand)] = &[("Right", ThreadHand::Right), ("Left", ThreadHand::Left)];
const STARTS: &[(&str, u32)] = &[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("5", 5), ("6", 6), ("7", 7), ("8", 8)];
const FULL: usize = 0;
@@ -268,9 +272,15 @@ fn is_buttress(form: ThreadForm) -> bool {
matches!(form, ThreadForm::Buttress | ThreadForm::ButtressAsme)
}
/// Sizes in inches are listed by designation, and their pitch is part of it (threads per inch).
/// Sizes in inches are listed by designation, and their pitch is part of it (threads per inch):
/// the inch forms and the pipe threads, whose nominal sizes are pipe sizes.
fn inch(form: ThreadForm) -> bool {
matches!(form, ThreadForm::Acme | ThreadForm::ButtressAsme)
matches!(form, ThreadForm::Acme | ThreadForm::ButtressAsme) || is_pipe(form)
}
/// A pipe thread: one start, turning one way.
fn is_pipe(form: ThreadForm) -> bool {
!form.pipe_sizes().is_empty()
}
fn same(a: f64, b: f64) -> bool {
@@ -395,8 +405,17 @@ pub(crate) fn rows(
*suggested = None;
}
pick(ui, "Hand", "thread_hand", &mut o.hand, HANDS);
pick(ui, "Starts", "thread_starts", &mut o.starts, STARTS);
if is_pipe(f) {
// A pipe thread has one start and turns one way.
o.starts = 1;
if o.hand == ThreadHand::Both {
o.hand = ThreadHand::Right;
}
pick(ui, "Hand", "thread_hand", &mut o.hand, HOLE_HANDS);
} else {
pick(ui, "Hand", "thread_hand", &mut o.hand, HANDS);
pick(ui, "Starts", "thread_starts", &mut o.starts, STARTS);
}
if is_buttress(f) {
pick(ui, "Steep Flank", "thread_flank", &mut o.steep_flank, FLANKS);
}
@@ -444,6 +463,13 @@ pub(crate) fn rows(
fn size_rows(ui: &mut egui::Ui, f: ThreadForm, designation: &mut String, suggested: &mut Option<String>) {
let table = f.sizes();
let current = parse(f, designation);
// ISO 7-1 writes the internal taper thread Rc: a size picked for a hole the dialog sized Rc
// stays Rc.
let internal = f == ThreadForm::Bspt && ThreadForm::pipe_internal(designation);
let designation_of = |f: ThreadForm, d: f64, p: f64| {
let name = designation_of(f, d, p);
if internal { name.replacen("R ", "Rc ", 1) } else { name }
};
let mut diameters: Vec<f64> = table.iter().map(|(d, _)| *d).collect();
if let Some((d, _)) = current
&& !diameters.iter().any(|n| same(*n, d))
@@ -230,6 +230,56 @@ fn the_forms_are_the_documents_and_square_is_not_offered() {
assert_eq!(example(ThreadForm::Trapezoidal), "Tr20x4");
}
/// The pipe threads: a G 1/2 shaft and an Rc 1/2 hole size themselves by the form, list their
/// sizes by designation without a pitch, and offer one start turning one way.
#[test]
fn pipe_forms_size_the_face_with_one_start_turning_one_way() {
let size = |h: &Harness| match &h.app.dialog.as_ref().unwrap().kind {
Kind::Thread { designation, .. } => designation.clone(),
k => panic!("{k:?}"),
};
let open = |hole: bool| {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1600,"height":1400}));
h.app.home.open = false;
if hole {
h.app.run("solid.box", json!({"corner":[-20,-20,0],"length":40,"width":40,"height":12,"body_name":"Body1"})).unwrap();
h.app.run("solid.cylinder", json!({"base":[0,0,-1],"radius":9.2,"height":14,"body_name":"Bore"})).unwrap();
h.app.run("solid.combine", json!({"target":"Body1","tools":["Bore"],"operation":"cut"})).unwrap();
} else {
h.app.run("solid.cylinder", json!({"radius":20.955 / 2.0,"height":15})).unwrap();
}
h.app.session.selection = vec![thread_face(&h.app, "Body1")];
h.step(&json!({"start":"solid.thread"})).unwrap();
h.frames(2);
h
};
let mut h = open(false);
choose(&mut h, "Form", "thread.form_bspp");
assert_eq!(size(&h), "G 1/2");
assert!(shown("Size") && shown("Hand") && !shown("Pitch") && !shown("Starts"));
choose(&mut h, "Hand", "Left");
let p = params(&h);
assert_eq!((&p["form"], &p["hand"]), (&json!("bspp"), &json!("left")));
choose(&mut h, "Form", "thread.form_bspt");
assert_eq!(size(&h), "R 1/2");
choose(&mut h, "Form", "thread.form_npt");
assert_eq!(params(&h)["form"], json!("npt"));
// A hand of both, from a form that has it, turns right on a pipe thread.
choose(&mut h, "Form", "ISO metric");
choose(&mut h, "Hand", "Both");
choose(&mut h, "Form", "thread.form_bspp");
assert!(params(&h).get("hand").is_none(), "right, the default");
let mut h = open(true);
choose(&mut h, "Form", "thread.form_bspt");
assert_eq!(size(&h), "Rc 1/2", "ISO 7-1 writes the internal taper thread Rc");
choose(&mut h, "Form", "thread.form_rp");
assert_eq!(size(&h), "Rp 1/2");
let (cmd, p) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
h.app.session.execute(&cmd, &p).unwrap_or_else(|e| panic!("Rp 1/2 in its hole: {e}"));
}
/// The face's size follows the form.
#[test]
fn the_face_size_follows_the_form() {
+6 -3
View File
@@ -24,6 +24,7 @@ modelled: a modelled thread is the maximum-material design profile below.
| 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 |
| Parallel internal (ISO 7-1) | `rp` | `Rp 1/2` | 27.5° / 27.5° | h = 0.640327 P of H = 0.960491 P | (H − h) tan 27.5° = 0.1667 P, both | (internal only) | 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 |
@@ -44,9 +45,11 @@ just past the crest, and the material beyond the major cone (external) or inside
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.
turns one way. A taper thread's chamfered tip (`tip`, with its `chamfer_depth`) is measured from the
major diameter the taper has at that end. 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. ISO 7-1's parallel
internal thread Rp (`rp`, `Rp 1/2`), the one an R screws into, has the parallel form (H = 0.960491
P) at the gauge diameters; it is internal only.
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.