mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
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:
@@ -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
@@ -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, §ion, 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);
|
||||
|
||||
Reference in New Issue
Block a user