threads: main's tapped-hole sizes and shaft turn-down on the reworked thread

Main gave the Thread feature two things the branch's thread rework (forms,
hands, starts, extents, runouts, tips on ThreadAsk) was written without:

- Tapped holes take catalogue sizes (ISO metric and the catalogued UNC/UNF,
  G and Rp sizes): `ThreadAsk::tapped` reads the designation from the tapped
  catalogue, names the thread as the catalogue does, and fits a size that is
  not metric to the hole drilled for it (its tap drill). The hole feature
  asks for its thread this way.
- A modelled external thread on a shaft thicker than it turns the shaft down
  to its major diameter first (#4256): main's `thread_turn` makes the ring,
  the shaft is cut by it, and the grooves and a chamfered tip are cut at the
  turned radius. Such a shaft fits the thread.

ThreadInfo keeps the radius of the face it is on, which main's cosmetic
thread display draws at (0 in a state saved without it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-26 13:03:04 -04:00
parent 9138e87040
commit d63aa814f4
2 changed files with 113 additions and 11 deletions
+4 -1
View File
@@ -6313,7 +6313,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
};
let mut t = thread_on_impl(st, f.id, wall, Some(d), None, true, false, *handedness)?;
// A tapped hole's screw direction, as the thread's hand.
let hand = if handedness.is_right() { ThreadHand::Right } else { ThreadHand::Left };
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand, ..Default::default() };
let mut t = thread_on(st, f.id, wall, &ask)?.info;
if let Some(depth) = thread_depth {
let available = t.end - t.start;
if depth > available {
+109 -10
View File
@@ -11,6 +11,10 @@ pub struct ThreadInfo {
pub body: String,
pub designation: String,
pub major_diameter: f64,
/// Radius of the actual cylindrical face when the cosmetic thread is evaluated (0 in a
/// state saved without it: the cosmetic display then leaves the thread out).
#[serde(default)]
pub radius: f64,
pub pitch: f64,
pub internal: bool,
pub axis_point: Vec3,
@@ -82,6 +86,9 @@ pub(super) struct ThreadAsk<'a> {
/// Runouts at the end the thread starts from and at the other, each as (mm, pitches): its
/// length is mm + pitches x the pitch.
pub ease: [(f64, f64); 2],
/// 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.
pub tapped: bool,
}
/// How one end of a thread's length meets its face.
@@ -162,9 +169,18 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
if starts > 8 {
return Err(DocError::Invalid("a thread has 1 to 8 starts".into()));
}
let size = match ask.designation {
Some(s) => form.parse(s, starts).map_err(DocError::Invalid)?,
None => {
// A tapped hole's size is the catalogue's.
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)"))
})?),
};
let size = match (&tapped, ask.designation) {
(Some(t), _) => crate::thread_forms::ThreadSize { form, major: t.major_diameter, pitch: t.pitch, lead: t.pitch },
(None, Some(s)) => form.parse(s, starts).map_err(DocError::Invalid)?,
(None, None) => {
let (d, p) = form.fit(dia, cyl.internal).ok_or_else(|| {
DocError::Invalid(if form.sizes().is_empty() {
format!("{} threads have no size table: give the designation ({})", form.name(), form.format(50.8, 25.4 / 8.0, 25.4 / 8.0))
@@ -182,12 +198,16 @@ 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 = form.format(major, pitch, size.lead);
let designation = tapped.as_ref().map(|t| t.designation.clone()).unwrap_or_else(|| form.format(major, pitch, size.lead));
let minor = 2.0 * form.groove(major, pitch, true).crest_r;
let fits = if cyl.internal {
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.
cyl.internal && (t.tap_drill - dia).abs() < 0.15 * major
} else if cyl.internal {
(minor - dia).abs() < 0.2 * major || (form.drill(major, pitch) - dia).abs() < 0.15 * major
} else {
(major - dia).abs() < 0.15 * major
// A modelled thread also fits a shaft thicker than it, which it turns down first.
(major - dia).abs() < 0.15 * major || (ask.modeled && dia > major)
};
if !fits {
return Err(DocError::Invalid(format!("an {designation} thread does not fit a {dia:.2} mm {}", if cyl.internal { "hole" } else { "shaft" })));
@@ -271,6 +291,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
body: mb.name.clone(),
designation,
major_diameter: major,
radius: cyl.radius,
pitch,
internal: cyl.internal,
axis_point: cyl.axis_point,
@@ -472,6 +493,73 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
/// one cut into its body.
/// What a modelled external thread turns off a shaft thicker than its major diameter before it
/// cuts, as a lathe would: the ring between the major diameter and the shaft over the threaded
/// length, run on past an end of the face that nothing of the part (`mb`) lies beyond. `None`
/// for a shaft no thicker than the thread. Its inner wall is the turned face, named `turned`;
/// an end that stops inside the face leaves a shoulder, `shoulder:start` or `shoulder:end`.
fn thread_turn(t: &ThreadInfo, cyl: &kernel::CylinderFace, mb: &ModelBody) -> Result<Option<Traced>> {
let (p, r) = (t.pitch, t.major_diameter / 2.0);
if t.internal || !(cyl.radius > r * (1.0 + 1e-6)) {
return Ok(None);
}
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 v = k.cross(u);
let mesh = mb.mesh();
let solid_round = |s: f64, radius: f64| {
(0..12).any(|i| {
let a = std::f64::consts::TAU * f64::from(i) / 12.0;
mesh.contains(t.axis_point + *k * s + (u * a.cos() + v * a.sin()) * radius)
})
};
// The ring reaches a little past the shaft; the part must not go on there (a shaft standing
// in a corner, a rounded edge), or the turning would cut into it.
let out = cyl.radius + 0.1 * p;
if [0.25, 0.5, 0.75].iter().any(|f| solid_round(t.start + (t.end - t.start) * f, cyl.radius + 0.05 * p)) {
return Err(DocError::Invalid(format!(
"this {} mm shaft is too thick for a modelled {} thread, and the part around it keeps it from being turned down; make the shaft {} mm",
show_mm(2.0 * cyl.radius),
t.designation,
show_mm(t.major_diameter)
)));
}
// Past a free end of the face out into the air; elsewhere square at the threaded length's end.
let tol = p * 1e-3;
let mid = (r + cyl.radius) / 2.0;
let free = |at: f64, s: f64, past: f64| (at - s).abs() < tol && !solid_round(s + past * 0.5 * p, mid);
let (open0, open1) = (free(cyl.start, t.start, -1.0), free(cyl.end, t.end, 1.0));
let s0 = if open0 { t.start - p } else { t.start };
let s1 = if open1 { t.end + p } else { t.end };
let plane = Plane { origin: t.axis_point, x: u, y: *k };
let ring = [Vec2::new(r, s0), Vec2::new(out, s0), Vec2::new(out, s1), Vec2::new(r, s1)];
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&ring), holes: vec![] };
let ring = kernel::revolve(&plane, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?
.into_iter()
.next()
.ok_or_else(|| DocError::Invalid("thread: the shaft turned down to the thread".into()))?;
// The ring's sides by profile segment: its start end, outside, end end, inner wall.
let role = |segment: usize| -> Vec<String> {
match segment {
0 if !open0 => vec!["shoulder:start".into()],
2 if !open1 => vec!["shoulder:end".into()],
3 => vec!["turned".into()],
_ => Vec::new(),
}
};
let roles = ring.history().filter(|h| h.operands == 0).map(|h| {
h.faces
.iter()
.map(|f| match f.origins.as_slice() {
[kernel::history::FaceOrigin::Profile { segment }] => role(*segment),
_ => Vec::new(),
})
.collect()
});
Ok(Some(Traced::made(ring, roles)))
}
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
let FeatureKind::Thread {
face,
@@ -514,6 +602,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
from: from.map(|q| q.0),
modeled: *modeled,
ease: [(0.0, 0.0); 2],
tapped: false,
};
// ISO 3508's runouts: standard 2.5 P, short 1.25 P.
let runout = |e: &Option<crate::ThreadRunout>| -> Result<(f64, f64)> {
@@ -535,9 +624,19 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
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())) };
// A shaft thicker than the thread is turned down to its major diameter first.
let input = mb.traced();
let (input, radius) = match thread_turn(&t, &cyl, &mb)? {
Some(ring) => {
let turned = kernel::boolean(&input.body, &ring.body, BoolOp::Cut)?
.ok_or_else(|| DocError::Invalid("turning the shaft down removed everything".into()))?;
(Traced::step(turned, &[&input, &ring]), t.major_diameter / 2.0)
}
None => (input, cyl.radius),
};
// The exact grooves: SMLib's screw sweeps, cut by one B-rep boolean.
let mut grooves = Vec::new();
for g in thread_grooves(&t, cyl.radius, (cyl.start, cyl.end), ends, steep, ease)? {
for g in thread_grooves(&t, radius, (cyl.start, cyl.end), ends, steep, ease)? {
let section = Region2 { outer: solidcraft_geom::Loop2::polygon(&g.section), holes: Vec::new() };
grooves.push(kernel::sweep_helix_radial(&g.plane, &section, g.base, g.axis, g.pitch, g.turns, &g.law)?);
}
@@ -545,16 +644,16 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
[one] => one.clone(),
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
};
let (nb, groove_faces) = (kernel::boolean(&mb.body, &tool, BoolOp::Cut)?, tool.face_count());
let (nb, groove_faces) = (kernel::boolean(&input.body, &tool, BoolOp::Cut)?, tool.face_count());
let nb = nb.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
// The groove's faces have no role to name them by.
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
let lineage = mb.traced().lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
let mut nb = Traced { body: nb, lineage };
if *tip == ThreadTip::Chamfer {
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, cyl.radius, ends, angle, depth)? {
for cone in tip_cones(&t, radius, ends, angle, depth)? {
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);