Tapped holes: Rc and NPT, and a modelled thread with the thread's End

The tapped-hole catalogue takes ISO 7-1's Rc and ASME B1.20.1's NPT internal taper threads,
every size of the tables a modelled thread cuts. Their drill is the internal minor diameter where
the thread ends (the reference plane's minor at the face less the 1 in 16 taper over the useful
or effective thread length), derived from the standards rather than a tap maker's table. An
external R is still no hole's.

A tapped hole's options mirror the thread's: `modeled` cuts its thread into the wall, from the
entry, by the same cut as `solid.thread` (now one function both call), and `ease_out` fades its
inner end out before the bottom (ISO 3508); without it the groove stops square at the thread
depth, clear of a blind hole's bottom. The Hole dialog's Tapped rows add Rc and NPT, Modeled
and the End choice (Square, Fade, Fade Length). The format notes in pending/thread-ends.rs and
pending/pipe-threads.rs record the new keys and sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-27 08:16:35 -04:00
committed by Irakli Kourtis
parent 80e47130b9
commit 9717fab2f3
12 changed files with 345 additions and 85 deletions
+8
View File
@@ -831,6 +831,14 @@ pub enum FeatureKind {
/// The tapped thread's hand: right (the default) or left; never both.
#[serde(default, skip_serializing_if = "crate::thread_forms::is_default")]
hand: crate::thread_forms::ThreadHand,
/// The tapped thread cut into the wall (the groove between its teeth, as a modelled
/// `solid.thread` cuts it) instead of only listed.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
/// A modelled tapped thread's inner end, towards the bottom: it fades out over this
/// length (ISO 3508's runout); none: it stops square, or clear of a blind hole's bottom.
#[serde(default, skip_serializing_if = "Option::is_none")]
ease_out: Option<ThreadRunout>,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<FacePick>,
+26 -3
View File
@@ -6285,11 +6285,17 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, hand, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
}
if *modeled && thread.is_none() {
return Err(DocError::Invalid("a modelled thread needs a tapped hole".into()));
}
if ease_out.is_some() && !*modeled {
return Err(DocError::Invalid("a runout is cut only on a modelled thread".into()));
}
if thread_depth.is_some_and(|d| !d.is_finite() || d <= 0.0) {
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
@@ -6305,14 +6311,31 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if let Some(d) = thread {
// Find the bore beyond any entry recess, including shallow and through holes.
let r = val(vals, diameter, Kind::Length)? / 2.0;
let parallel_pipe = crate::pipe_thread_catalog::parse_pipe_thread(d).is_some();
// A pipe thread's wall must be found: no fallback place on a guessed wall.
let pipe = crate::parse_tapped_thread(d).is_some_and(|t| {
matches!(
t.family,
crate::TappedThreadFamily::G | crate::TappedThreadFamily::Rp | crate::TappedThreadFamily::Rc | crate::TappedThreadFamily::Npt
)
});
let ease = thread::runout(vals, ease_out)?;
for p in spots {
let wall = if parallel_pipe {
let wall = if pipe {
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
};
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
if *modeled {
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
// at a blind hole's bottom the groove stops clear of it, or fades out
// before it with a runout.
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
let on = thread_on(st, f.id, wall, &ask)?;
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
continue;
}
let mut t = thread_on(st, f.id, wall, &ask)?.info;
if let Some(depth) = thread_depth {
let available = t.end - t.start;
+29 -21
View File
@@ -87,7 +87,7 @@ pub(super) struct ThreadAsk<'a> {
/// is mm + pitches x the pitch. The start never fades: it runs off its free end.
pub ease_out: (f64, f64),
/// A tapped hole's thread: `designation` is a catalogue size (ISO metric, or a catalogued
/// UNC/UNF, G or Rp size), and a size that is not metric fits the hole drilled for it.
/// UNC/UNF, G, Rp, Rc or NPT size), and a size that is not metric fits the hole drilled for it.
pub tapped: bool,
}
@@ -185,10 +185,9 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
let tapped = match (ask.tapped, ask.designation) {
(false, _) => None,
(true, None) => return Err(DocError::Invalid("a tapped hole needs a designation".into())),
(true, Some(name)) => Some(
crate::parse_tapped_thread(name)
.ok_or_else(|| DocError::Invalid(format!("unknown tapped thread `{name}` (ISO metric or a catalogued UNC/UNF, G or Rp size)")))?,
),
(true, Some(name)) => Some(crate::parse_tapped_thread(name).ok_or_else(|| {
DocError::Invalid(format!("unknown tapped thread `{name}` (ISO metric or a catalogued UNC/UNF, G, Rp, Rc or NPT size)"))
})?),
};
let size = match (&tapped, ask.designation) {
(Some(t), _) => crate::thread_forms::ThreadSize { form, major: t.major_diameter, pitch: t.pitch, lead: t.pitch },
@@ -707,24 +706,35 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
ease_out: (0.0, 0.0),
tapped: false,
};
// ISO 3508's runouts: standard 2.5 P, short 1.25 P.
let runout = |e: &Option<crate::ThreadRunout>| -> Result<(f64, f64)> {
Ok(match e {
None => (0.0, 0.0),
Some(crate::ThreadRunout::Standard) => (0.0, 2.5),
Some(crate::ThreadRunout::Short) => (0.0, 1.25),
Some(crate::ThreadRunout::Length { length }) => (val(vals, length, Kind::Length)?, 0.0),
})
};
if ease_out.is_some() && !*modeled {
return Err(DocError::Invalid("a runout is cut only on a modelled thread".into()));
}
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)?;
let ask = ThreadAsk { ease_out: runout(vals, ease_out)?, ..ask };
let on = thread_on(st, f.id, face.0, &ask)?;
if !*modeled {
st.threads.push(t);
st.threads.push(on.info);
return Ok(());
}
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()?;
cut(st, on, (*tip, angle, depth))
}
/// A runout as (mm, pitches): ISO 3508's standard 2.5 P and short 1.25 P, or a length.
pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRunout>) -> Result<(f64, f64)> {
Ok(match e {
None => (0.0, 0.0),
Some(crate::ThreadRunout::Standard) => (0.0, 2.5),
Some(crate::ThreadRunout::Short) => (0.0, 1.25),
Some(crate::ThreadRunout::Length { length }) => (val(vals, length, Kind::Length)?, 0.0),
})
}
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
/// (`tip`, with its angle and depth when given).
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<()> {
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side } = on;
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())) };
let (input, radius) = (mb.traced(), cyl.radius);
@@ -758,10 +768,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
let mut nb = Traced { body: nb, lineage };
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, taper)? {
if tip != ThreadTip::None {
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);
@@ -1,5 +1,5 @@
//! 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).
//! 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`), and a tapped hole's `thread` may be
//! `Rc 1/2` or `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).
+4 -2
View File
@@ -1,8 +1,10 @@
//! A modelled thread saves how its ends are finished: `ease_out`, the runout at the inner end,
//! away from where it starts (`standard` 2.5 P, `short` 1.25 P or `{length}`, ISO 3508), and
//! `tip`, which free ends are chamfered (`start`: the end it starts from, `both`: each free end;
//! ISO 4753), with `chamfer_angle` and `chamfer_depth`. Without them, as in an older design, the
//! groove stops square and no end is chamfered. Designs saved while these were being made are
//! ISO 4753), with `chamfer_angle` and `chamfer_depth`. A tapped hole saves `modeled` (its thread
//! cut into the wall) and that thread's `ease_out` towards the bottom. Without them, as in an
//! older design, the groove stops square, no end is chamfered and a tapped hole's thread is only
//! listed. Designs saved while these were being made are
//! rewritten as they open: `tip: chamfer` (every free end) becomes `both`, and an `ease_in` (a
//! runout at the start, which now always runs off its free end) is dropped. The last release
//! refuses a design saved with this (the newer format number).
+76 -7
View File
@@ -8,6 +8,10 @@ pub enum TappedThreadFamily {
Unf,
G,
Rp,
/// ISO 7-1's internal taper thread (`Rc 1/2`).
Rc,
/// ASME B1.20.1's taper pipe thread, internal (`1/2-14 NPT`).
Npt,
}
#[derive(Clone, Debug, PartialEq)]
@@ -22,7 +26,7 @@ pub struct TappedThread {
impl TappedThread {
pub fn profile_degrees(&self) -> f64 {
match self.family {
TappedThreadFamily::G | TappedThreadFamily::Rp => 55.0,
TappedThreadFamily::G | TappedThreadFamily::Rp | TappedThreadFamily::Rc => 55.0,
_ => 60.0,
}
}
@@ -42,6 +46,24 @@ fn pipe_size(size: crate::pipe_thread_catalog::PipeThread) -> TappedThread {
}
}
/// An internal taper thread (Rc, NPT) of the size tables a modelled thread cuts
/// ([`crate::pipe_thread_sizes`]). Its drill is the internal minor diameter where the thread
/// ends, the reference plane's d - 2h at the face less the taper over the thread's length (ISO
/// 7-1's useful thread, NPT's effective thread L2): derived from the standards' dimensions, so
/// the tap cuts full form over the whole length. It is not a tap maker's recommendation.
fn taper_size(family: TappedThreadFamily, size: crate::pipe_thread_sizes::PipeSize) -> TappedThread {
use crate::thread_forms::ThreadForm;
let p = size.pitch();
let (form, h) = match family {
TappedThreadFamily::Npt => (ThreadForm::Npt, crate::pipe_thread_sizes::NPT_HEIGHT * p),
_ => (ThreadForm::Bspt, 0.640327 * p),
};
let designation = form.format(size.major, p, p);
let designation = if form == ThreadForm::Bspt { designation.replacen("R ", "Rc ", 1) } else { designation };
let drill = size.major - 2.0 * h - size.length / 16.0;
TappedThread { family, designation, major_diameter: size.major, pitch: p, tap_drill: (drill * 1000.0).floor() / 1000.0 }
}
// Diameter/pitch pairs verified against Guhring, plus Yamawa for M12x1.25.
const METRIC_FINE: &[(f64, f64)] = &[
(3.0, 0.35),
@@ -110,6 +132,11 @@ pub fn tapped_thread_sizes(family: TappedThreadFamily) -> Vec<TappedThread> {
if matches!(family, TappedThreadFamily::G | TappedThreadFamily::Rp) {
return crate::pipe_thread_catalog::pipe_thread_sizes().map(pipe_size).filter(|s| s.family == family).collect();
}
match family {
TappedThreadFamily::Rc => return crate::pipe_thread_sizes::iso_7_1().map(|z| taper_size(family, z)).collect(),
TappedThreadFamily::Npt => return crate::pipe_thread_sizes::npt().map(|z| taper_size(family, z)).collect(),
_ => {}
}
if family == TappedThreadFamily::Metric {
let mut sizes: Vec<_> = crate::metric_thread_sizes().iter().chain(METRIC_FINE).map(|&(d, p)| metric_size(d, p)).collect();
@@ -131,7 +158,8 @@ pub fn tapped_thread_sizes(family: TappedThreadFamily) -> Vec<TappedThread> {
}
/// Explicit metric custom sizes remain supported. Unified sizes must be in the verified
/// catalogue and include their UNC/UNF suffix. G/Rp use explicit process drills; modeled Thread stays metric-only.
/// catalogue and include their UNC/UNF suffix. G/Rp use explicit process drills, Rc/NPT the drill
/// derived from their standards ([`taper_size`]).
pub fn parse_tapped_thread(text: &str) -> Option<TappedThread> {
if text.len() > 128 {
return None;
@@ -142,6 +170,18 @@ pub fn parse_tapped_thread(text: &str) -> Option<TappedThread> {
if let Some(size) = crate::pipe_thread_catalog::parse_pipe_thread(text) {
return Some(pipe_size(size));
}
// An internal taper thread: Rc or NPT (an external R is no hole's).
use crate::thread_forms::ThreadForm;
let taper = match ThreadForm::of_designation(text) {
Some(ThreadForm::Bspt) if ThreadForm::pipe_internal(text) => Some((TappedThreadFamily::Rc, ThreadForm::Bspt)),
Some(ThreadForm::Bspt) => return None,
Some(ThreadForm::Npt) => Some((TappedThreadFamily::Npt, ThreadForm::Npt)),
_ => None,
};
if let Some((family, form)) = taper {
let size = form.parse(text, 1).ok()?;
return tapped_thread_sizes(family).into_iter().find(|s| (s.major_diameter - size.major).abs() < 1e-9);
}
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_uppercase();
let &(name, family, major_diameter, tpi, tap_drill) = UNIFIED.iter().find(|row| row.0 == normalized)?;
Some(TappedThread { family, designation: name.into(), major_diameter, pitch: 25.4 / f64::from(tpi), tap_drill })
@@ -163,14 +203,43 @@ mod tests {
}
}
/// Rc and NPT holes: the size tables' diameters, the internal minor where the thread ends for
/// the drill (Rc 1/2: d1 18.631 at the face, 13.2 mm of useful thread at 1 in 16; 1/2 NPT:
/// E1 - h = 0.77843 - 0.05714 in at the face, L2 = 0.5337 in), and no external R.
#[test]
fn internal_taper_threads_drill_to_their_minor_where_the_thread_ends() {
let rc = parse_tapped_thread("Rc1/2").unwrap();
assert_eq!((rc.family, rc.designation.as_str(), rc.major_diameter), (TappedThreadFamily::Rc, "Rc 1/2", 20.955));
assert!((rc.tap_drill - (18.631 - 13.2 / 16.0)).abs() < 2e-3, "{}", rc.tap_drill);
let npt = parse_tapped_thread("1/2 NPT").unwrap();
assert_eq!((npt.family, npt.designation.as_str()), (TappedThreadFamily::Npt, "1/2-14 NPT"));
assert!((npt.tap_drill - (0.77843 - 0.8 / 14.0 - 0.5337 / 16.0) * 25.4).abs() < 2e-3, "{}", npt.tap_drill);
assert_eq!(rc.profile_degrees(), 55.0);
assert_eq!(npt.profile_degrees(), 60.0);
assert!(parse_tapped_thread("R 1/2").is_none(), "an external taper thread is no hole's");
}
#[test]
fn tapped_catalogue_has_unique_valid_sizes_and_round_trips() {
let mut names = std::collections::HashSet::new();
for family in [TappedThreadFamily::Metric, TappedThreadFamily::Unc, TappedThreadFamily::Unf, TappedThreadFamily::G, TappedThreadFamily::Rp] {
for family in [
TappedThreadFamily::Metric,
TappedThreadFamily::Unc,
TappedThreadFamily::Unf,
TappedThreadFamily::G,
TappedThreadFamily::Rp,
TappedThreadFamily::Rc,
TappedThreadFamily::Npt,
] {
let sizes = tapped_thread_sizes(family);
if family != TappedThreadFamily::Metric {
assert_eq!(sizes.len(), if matches!(family, TappedThreadFamily::G | TappedThreadFamily::Rp) { 6 } else { 14 });
}
let count = match family {
TappedThreadFamily::Metric => sizes.len(),
TappedThreadFamily::G | TappedThreadFamily::Rp => 6,
TappedThreadFamily::Rc => 15,
TappedThreadFamily::Npt => crate::pipe_thread_sizes::npt().count(),
_ => 14,
};
assert_eq!(sizes.len(), count, "{family:?}");
for size in sizes {
assert!(names.insert(size.designation.clone()));
assert_eq!(parse_tapped_thread(&size.designation), Some(size.clone()));
@@ -200,7 +269,7 @@ mod tests {
let size = parse_tapped_thread(name).unwrap();
assert_eq!((size.major_diameter, size.pitch, size.tap_drill), (d, p, drill));
}
for invalid in ["1/4-20 UNF", "#10-32 UNC", "1/4-19 BSP", "Rc1/4", "R1/4", "1/4-18 NPT", "1/4-20", "M8xNaN", "M8x8"] {
for invalid in ["1/4-20 UNF", "#10-32 UNC", "1/4-19 BSP", "Rc 7/8", "R1/4", "1/4-27 NPT", "1/4-20", "M8xNaN", "M8x8"] {
assert!(parse_tapped_thread(invalid).is_none(), "{invalid}");
}
}
+29 -16
View File
@@ -69,7 +69,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: metric major minus pitch or tabulated UNC/UNF, G or Rp cut-tap drill) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" or \"1/4-20 UNC\" (cosmetic); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: metric major minus pitch or tabulated UNC/UNF, G or Rp cut-tap drill, or the Rc/NPT drill to the internal minor diameter where the thread ends) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
@@ -2434,13 +2434,21 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(
cmd,
fine_only_hint(t).unwrap_or_else(|| format!("unknown tapped thread `{t}` (ISO metric or a catalogued UNC/UNF, G or Rp size)")),
fine_only_hint(t).unwrap_or_else(|| format!("unknown tapped thread `{t}` (ISO metric or a catalogued UNC/UNF, G, Rp, Rc or NPT size)")),
));
}
let thread_depth = match p.get("thread_depth") {
None | Some(Value::Null) => None,
Some(_) => Some(req_expr(cmd, p, "thread_depth")?),
};
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
if modeled && thread.is_none() {
return Err(bad(cmd, "`modeled` needs `thread` (a tapped hole)"));
}
let ease_out = runout_param(s, p, cmd, "ease_out")?;
if ease_out.is_some() && !modeled {
return Err(bad(cmd, "`ease_out` is cut only on a modelled thread (`modeled: true`)"));
}
if let Some(d) = &thread_depth {
if thread.is_none() {
return Err(bad(cmd, "`thread_depth` requires `thread` (a tapped hole)"));
@@ -2602,12 +2610,30 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
thread,
thread_depth,
hand,
modeled,
ease_out,
to: to.map(FacePick),
references,
},
)
}
/// A thread's runout parameter: a length, `standard` (ISO 3508's 2.5 P) or `short` (1.25 P);
/// none or 0: no runout.
fn runout_param(s: &Session, p: &Value, cmd: &str, 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 })
}
})
}
/// A hole's reference edges (#37): `references: [{edge: [x, y, z], distance: expr}]`, at most
/// two, each distance a length.
fn hole_reference_params(s: &Session, p: &Value, cmd: &str) -> Result<Vec<solidcraft_doc::HoleReference>> {
@@ -2964,26 +2990,13 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
if tip == ThreadTip::None && (chamfer_angle.is_some() || chamfer_depth.is_some()) {
return Err(bad(cmd, "`chamfer_angle` and `chamfer_depth` go with a chamfered tip (`tip: start` or `both`)"));
}
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 })
}
})
};
if p.get("ease_in").is_some() {
return Err(bad(
cmd,
"`ease_in` is gone: a thread's start runs off its free end, plain or chamfered (`tip: start`); fade the inner end with `ease_out`",
));
}
let ease_out = ease("ease_out")?;
let ease_out = runout_param(s, p, cmd, "ease_out")?;
let length = expr(p, "length");
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
+44
View File
@@ -1212,3 +1212,47 @@ fn a_taper_pipe_thread_starts_at_a_free_end_and_is_named_for_its_side() {
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}");
}
/// A tapped hole's thread cut into its wall (`modeled`), as the thread feature cuts one: an M8
/// blind hole whose groove stops clear of the drill point's bottom, or fades out before it
/// (`ease_out`, ISO 3508), which leaves more of the wall than the full form there; an Rc 1/2
/// through a plate, drilled to its minor diameter where the thread ends and cut to its taper;
/// each a valid body that reopens the same.
#[test]
fn a_modelled_tapped_hole_stops_clear_of_its_bottom_or_fades_out_before_it() {
let blind = |extra: Value| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":30,"width":30,"height":20,"body_name":"Plate"}));
let mut p = json!({"position":[15,15,20],"direction":[0,0,-1],"thread":"M8","depth":14,"type":"drilled"});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
run(&mut s, "solid.hole", p);
assert!(s.model.state().body("Plate").unwrap().body.validity().is_empty(), "{extra}");
s
};
let (drilled, square, fade) = (blind(json!({})), blind(json!({"modeled":true})), blind(json!({"modeled":true,"ease_out":"standard"})));
let (v0, v1, v2) = (exact_volume(&drilled, "Plate"), exact_volume(&square, "Plate"), exact_volume(&fade, "Plate"));
assert!(v1 < v2 && v2 < v0, "drilled {v0}, square {v1}, fade {v2}");
// The groove never reaches the drill point: the bottom's cone is the drilled one.
assert!(square.model.state().threads.is_empty(), "a modelled thread is cut, not listed");
let reopened = Session::new(solidcraft_doc::Document::from_json(&fade.doc.to_json()).unwrap());
assert!((exact_volume(&reopened, "Plate") - v2).abs() < 1e-6);
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":40,"height":12,"body_name":"Plate"}));
let out = run(&mut s, "solid.hole", json!({"position":[20,20,12],"direction":[0,0,-1],"thread":"Rc 1/2"}));
let drilled = exact_volume(&s, "Plate");
// d1 at the face less 13.2 mm of useful thread at 1 in 16, to the micrometre below.
let drill = ((20.955_f64 - 2.0 * 0.640327 * 25.4 / 14.0 - 13.2 / 16.0) * 1000.0).floor() / 1000.0;
assert!((drilled - (40.0 * 40.0 * 12.0 - PI * (drill / 2.0).powi(2) * 12.0)).abs() < 1e-3, "{drilled}");
let id = out["feature"].as_u64().unwrap();
let redefine = json!({"position":[20,20,12],"direction":[0,0,-1],"thread":"Rc 1/2","modeled":true});
run(&mut s, "timeline.redefine", json!({"feature":id,"command":"solid.hole","params":redefine}));
assert!(s.model.state().body("Plate").unwrap().body.validity().is_empty());
assert!(exact_volume(&s, "Plate") < drilled - 1.0);
let err = |p: Value| Session::default().execute("solid.hole", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(err(json!({"position":[0,0,0],"diameter":5,"modeled":true})).contains("`modeled` needs `thread`"));
assert!(err(json!({"position":[0,0,0],"thread":"M8","ease_out":"standard"})).contains("modelled thread"));
}
+31 -2
View File
@@ -420,6 +420,10 @@ pub struct HoleOpts {
pub thread_depth: String,
/// The tapped thread's hand (right or left).
pub hand: solidcraft_engine::doc::thread_forms::ThreadHand,
/// The tapped thread cut into the wall, not only listed.
pub modeled: bool,
/// A modelled tapped thread's inner end, towards the bottom: square or a fade.
pub ease_out: crate::dialogs_thread::Ease,
}
impl Default for HoleOpts {
@@ -436,6 +440,8 @@ impl Default for HoleOpts {
thread_full_depth: true,
thread_depth: "10 mm".into(),
hand: solidcraft_engine::doc::thread_forms::ThreadHand::Right,
modeled: false,
ease_out: crate::dialogs_thread::Ease::default(),
}
}
}
@@ -4487,7 +4493,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
return Err(error.clone());
}
solidcraft_engine::doc::parse_tapped_thread(&opts.thread)
.ok_or("select a valid metric or catalogued UNC/UNF, G or Rp thread size")?;
.ok_or("select a valid metric or catalogued UNC/UNF, G, Rp, Rc or NPT thread size")?;
params["thread"] = json!(opts.thread.trim());
if !opts.thread_full_depth {
params["thread_depth"] = json!(opts.thread_depth.trim());
@@ -4495,6 +4501,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
if opts.hand != solidcraft_engine::doc::thread_forms::ThreadHand::Right {
params["hand"] = json!(opts.hand);
}
if opts.modeled {
params["modeled"] = json!(true);
if let Some(e) = opts.ease_out.param() {
params["ease_out"] = e;
}
}
}
match *kind {
1 => {
@@ -5416,7 +5428,22 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.extra.insert("pull".into(), pt3(pull.0));
d
}
FeatureKind::Hole { position, at, direction, diameter, depth, hole, points, thread, thread_depth, hand, to, references } => {
FeatureKind::Hole {
position,
at,
direction,
diameter,
depth,
hole,
points,
thread,
thread_depth,
hand,
modeled,
ease_out,
to,
references,
} => {
let mut d = start("solid.hole")?;
let mut k = hole_defaults(s);
if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = &mut k {
@@ -5428,6 +5455,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
opts.tapped = true;
opts.thread = th.clone();
opts.hand = *hand;
opts.modeled = *modeled;
opts.ease_out = crate::dialogs_thread::Ease::of(ease_out);
}
opts.thread_full_depth = thread_depth.is_none();
if let Some(depth) = thread_depth {
+5 -1
View File
@@ -568,7 +568,11 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
if opts.tapped {
enter |= crate::dialogs_thread::hole_rows(ui, &app.session.doc, &mut opts.thread, &mut opts.custom_thread);
crate::dialogs_thread::hole_hand_row(ui, &mut opts.hand);
ui_kit::checkbox_grid(ui, &mut [("Full thread depth", &mut opts.thread_full_depth)], 1);
ui_kit::checkbox_grid(ui, &mut [("Full thread depth", &mut opts.thread_full_depth), ("Modeled", &mut opts.modeled)], 1);
// A modelled thread's inner end, towards the bottom, as the thread dialog's.
if opts.modeled {
enter |= crate::dialogs_thread::end_rows(ui, &mut opts.ease_out);
}
}
// Through all, the tip lies past the part.
if !opts.all {
+63 -16
View File
@@ -70,11 +70,19 @@ impl HoleThreadDraft {
/// Custom remains a dialog mode even when its current values match a catalogue size.
pub(crate) fn hole_rows(ui: &mut egui::Ui, doc: &Document, designation: &mut String, custom: &mut Option<HoleThreadDraft>) -> bool {
let designation_id = ui.make_persistent_id("hole_thread_designation");
let families = [TappedThreadFamily::Metric, TappedThreadFamily::Unc, TappedThreadFamily::Unf, TappedThreadFamily::G, TappedThreadFamily::Rp];
let families = [
TappedThreadFamily::Metric,
TappedThreadFamily::Unc,
TappedThreadFamily::Unf,
TappedThreadFamily::G,
TappedThreadFamily::Rp,
TappedThreadFamily::Rc,
TappedThreadFamily::Npt,
];
let family = parse_tapped_thread(designation).map(|s| s.family).unwrap_or(TappedThreadFamily::Metric);
let mut profile = families.iter().position(|f| *f == family).unwrap_or(0);
let before_profile = profile;
form_row(ui, "Profile", |ui| combo(ui, "hole_thread_profile", &["ISO metric", "UNC", "UNF", "G / BSPP", "Rp"], &mut profile));
form_row(ui, "Profile", |ui| combo(ui, "hole_thread_profile", &["ISO metric", "UNC", "UNF", "G / BSPP", "Rp", "Rc", "NPT"], &mut profile));
let family = families.get(profile).copied().unwrap_or(TappedThreadFamily::Metric);
let sizes = tapped_thread_sizes(family);
if family != TappedThreadFamily::Metric {
@@ -107,6 +115,8 @@ pub(crate) fn hole_rows(ui: &mut egui::Ui, doc: &Document, designation: &mut Str
TappedThreadFamily::Unf => "hole_thread_catalogue_unf",
TappedThreadFamily::G => "hole_thread_catalogue_g",
TappedThreadFamily::Rp => "hole_thread_catalogue_rp",
TappedThreadFamily::Rc => "hole_thread_catalogue_rc",
TappedThreadFamily::Npt => "hole_thread_catalogue_npt",
};
combo(ui, catalogue_id, &labels, &mut selected);
if selected != before {
@@ -118,10 +128,10 @@ pub(crate) fn hole_rows(ui: &mut egui::Ui, doc: &Document, designation: &mut Str
}
}
});
if matches!(family, TappedThreadFamily::G | TappedThreadFamily::Rp) {
if matches!(family, TappedThreadFamily::G | TappedThreadFamily::Rp | TappedThreadFamily::Rc | TappedThreadFamily::Npt) {
form_row(ui, "Thread diameter", |ui| {
if let Some(size) = parse_tapped_thread(designation) {
ui.label(format!("{:.3} mm, 55°", size.major_diameter));
ui.label(format!("{:.3} mm, {}°", size.major_diameter, size.profile_degrees()));
}
});
form_row(ui, "Pitch", |ui| {
@@ -205,7 +215,7 @@ pub struct Ease {
}
impl Ease {
fn of(r: &Option<ThreadRunout>) -> Ease {
pub(crate) fn of(r: &Option<ThreadRunout>) -> Ease {
match r {
None => Ease::default(),
Some(ThreadRunout::Standard) => Ease { fade: true, ..Ease::default() },
@@ -215,7 +225,7 @@ impl Ease {
}
/// The command's `ease_out`, none for a square end.
fn param(&self) -> Option<Value> {
pub(crate) fn param(&self) -> Option<Value> {
let typed = self.length.trim();
self.fade.then(|| match (typed.is_empty(), self.short) {
(false, _) => json!(typed),
@@ -446,18 +456,24 @@ pub(crate) fn rows(
let depth = if f == ThreadForm::Metric { "ui.iso_4753" } else { "thread.chamfer_depth_needed" };
enter |= hint_row(ui, "Chamfer Depth", &mut o.chamfer_depth, tr(depth));
}
// The inner end: square, or a fade over its length.
let mut fade = usize::from(o.ease_out.fade);
choice_row(ui, "End", "thread_end", &mut fade, ENDS_AT);
o.ease_out.fade = fade == 1;
if o.ease_out.fade {
let hint = if o.ease_out.short { tr("thread.fade_short") } else { tr("thread.fade_standard") };
enter |= hint_row(ui, "Fade Length", &mut o.ease_out.length, hint);
}
enter |= end_rows(ui, &mut o.ease_out);
});
(enter, reset)
}
/// The inner end of a modelled thread (a thread's, or a tapped hole's towards its bottom):
/// square, or a fade over its length.
pub(crate) fn end_rows(ui: &mut egui::Ui, ease: &mut Ease) -> bool {
let mut fade = usize::from(ease.fade);
choice_row(ui, "End", "thread_end", &mut fade, ENDS_AT);
ease.fade = fade == 1;
if !ease.fade {
return false;
}
let hint = if ease.short { tr("thread.fade_short") } else { tr("thread.fade_standard") };
hint_row(ui, "Fade Length", &mut ease.length, hint)
}
/// The Size choice of a form with a size table, and the Pitch choice of the metric ones (an
/// inch designation names its threads per inch).
fn size_rows(ui: &mut egui::Ui, f: ThreadForm, designation: &mut String, suggested: &mut Option<String>) {
@@ -766,6 +782,37 @@ mod contribution_tests {
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
}
/// A tapped hole offers what a thread does: Modeled, and then its inner end, square or a
/// fade, towards the bottom. OK cuts it; editing the hole brings them back.
#[test]
fn a_tapped_hole_is_modelled_with_the_threads_end() {
let (app, dialog) = hole();
let mut h = crate::scenario::Harness::new();
h.app = app;
h.app.dialog = Some(dialog);
h.frames(3);
for handle in ["dialog:Hole Tap Type", "dialog:Hole Tap Type:Tapped"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
h.frames(2);
assert!(crate::scenario::rect_of("dialog:row:End").is_none(), "a listed thread has no end to finish");
h.step(&json!({"click":{"handle":"dialog:Modeled"}})).unwrap();
h.frames(2);
for handle in ["dialog:End", "dialog:End:ui.fade"] {
h.step(&json!({"click":{"handle":handle}})).unwrap();
}
h.frames(2);
let (cmd, p) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
assert_eq!((&p["modeled"], &p["ease_out"]), (&json!(true), &json!("standard")), "{p}");
let id = h.app.run(&cmd, p.clone()).unwrap()["feature"].as_u64().unwrap();
h.app.dialog = None;
h.app.edit_feature(id);
h.frames(2);
let (_, again) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0);
let again = if again.get("params").is_some() { again["params"].clone() } else { again };
assert_eq!((&again["modeled"], &again["ease_out"]), (&json!(true), &json!("standard")), "{again}");
}
#[test]
fn manual_designation_keeps_keyboard_focus_when_custom_fields_appear() {
let (app, dialog) = hole();
@@ -937,10 +984,10 @@ mod contribution_tests {
}
#[test]
fn expanded_catalogue_rejects_tapered_holes_and_nonmetric_thread_features() {
fn expanded_catalogue_rejects_external_taper_holes_and_nonmetric_thread_features() {
let (mut app, _) = hole();
let before = app.session.doc.to_json();
for name in ["1/4-18 NPT", "Rc1/4", "R1/4", "1/4-20 UNF"] {
for name in ["R1/4", "1/4-20 UNF"] {
assert!(app.run("solid.hole", json!({"position":[10,10,10],"thread":name})).is_err());
assert_eq!(app.session.doc.to_json(), before);
}
+26 -13
View File
@@ -1,8 +1,9 @@
# Tapped-hole catalogue
The Hole dialog offers ISO metric coarse sizes, 15 additional fine diameter/pitch
pairs, a finite set of 14 UNC and 14 UNF sizes, and six sizes each for internal
G/BSPP and Rp parallel pipe threads. This is a verified subset,
pairs, a finite set of 14 UNC and 14 UNF sizes, six sizes each for internal
G/BSPP and Rp parallel pipe threads, and the ISO 7-1 Rc and ASME B1.20.1 NPT
internal taper threads (every size of their tables, `docs/threads.md`). This is a verified subset,
not complete coverage of these standards. Unified sizes range from #4 through
#12 and 1/4 through 1 inch. Include the family in a designation, for example
`1/4-20 UNC` or `#10-32 UNF`.
@@ -26,11 +27,23 @@ not presented as a tabulated 75% cut-tap recommendation. Existing files retain
their saved diameter expressions. Custom metric dimensions remain editable, and
Custom stays selected even when its current dimensions match a catalogue entry.
Tapped holes create the physical pilot bore and cosmetic thread metadata. The
separate Thread command and its automatic sizing remain ISO metric only; this
catalogue does not enable modelled Unified or pipe threads. Rc, external R, NPT,
thread classes, handedness and sealing behavior are outside this tranche.
No tapered thread is represented as a cylindrical hole.
Tapped holes create the physical pilot bore and the thread, listed (cosmetic) or,
with `modeled`, cut into the wall as the Thread command cuts a modelled thread:
the entry is where it starts, and towards the bottom it stops square at the
thread depth, clear of a blind hole's bottom, or fades out (`ease_out`, ISO 3508).
A Unified thread is cut with the ISO 68-1 basic profile both share. External R,
thread classes and sealing behaviour are outside this catalogue.
## Taper Rc and NPT threads
An Rc or NPT hole is drilled to the internal thread's minor diameter where the
thread ends: the reference plane's minor (d - 2h for ISO 7-1, E1 - h for NPT) at
the face, less the 1 in 16 taper over the thread's length (ISO 7-1's useful
thread, NPT's effective thread L2). The drill is derived from the standards'
dimensions so that the tap cuts full form over the whole length; it is not a tap
maker's recommendation, and an explicit `diameter` overrides it. Listed, the hole
is that cylindrical drill; modelled, the thread's taper is cut into it (the tap's
minor cone and the helical groove).
## Parallel G/BSPP and Rp threads
@@ -38,8 +51,8 @@ Both families use a 55-degree profile and a cylindrical pilot bore. Keep G and R
separate: their process-drill recommendations differ. The UI shows the nominal
pipe designation, actual basic major diameter, pitch/TPI and pilot drill. The
command accepts canonical labels such as `G 1/4` and `Rp 1/4`, compact forms
`G1/4` and `Rp1/4`, and `BSPP 1/4` as an alias for G. The separate Thread command
remains metric-only.
`G1/4` and `Rp1/4`, and `BSPP 1/4` as an alias for G. The Thread command cuts
them too (forms `bspp` and `rp`).
| Nominal | TPI | Major mm | Pitch diameter mm | Minor mm | G drill mm | Rp drill mm |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
@@ -54,8 +67,8 @@ Pitch is exactly 25.4/TPI. These basic thread dimensions are separate from proce
bores. The catalogue uses Yamawa's drill column as its default pilot. Its separate
recommended process-bore references, for the six rows above, are G: 8.78, 11.78,
15.28, 19.04, 24.52, 30.77 mm and Rp: 8.55, 11.5, 14.9, 18.55, 24.1, 30.25 mm.
A saved explicit diameter remains authoritative. The geometry stays a physical
cylindrical pilot plus cosmetic designation, without a helical cut.
A saved explicit diameter remains authoritative. Listed, the geometry is the
cylindrical pilot plus the designation; modelled, the helical groove is cut too.
## Primary sources
@@ -79,8 +92,8 @@ cylindrical pilot plus cosmetic designation, without a helical cut.
## Trial
On a small plate, open Hole, choose Tapped, then switch Profile between ISO metric,
UNC, UNF, G / BSPP and Rp. Try M8, M8x1, 1/4-20 UNC, #10-32 UNF, G 1/4
and Rp 1/4. Check the displayed drill,
UNC, UNF, G / BSPP, Rp, Rc and NPT. Try M8, M8x1, 1/4-20 UNC, #10-32 UNF, G 1/4,
Rp 1/4, Rc 1/4 and 1/4-18 NPT, listed and Modeled, with the End Square or Fade. Check the displayed drill,
create the hole, save, reopen and check the designation and bore. In ISO metric,
choose Custom, edit the nominal diameter and pitch, and verify that Custom stays
selected and an invalid draft cannot apply an earlier valid value.