Thread runouts: the groove fades out instead of stopping square

Ladder 06/009 had no way to ask for one: "solid.thread has no runout (vanishing thread)
length". With SMLib's smlib_screw_sweep_radial (replay e7acea1):

- smlib: the FFI binding and Brep::screw_sweep_radial, the profile moved off the axis by a
  piecewise linear law of the turn; crate test: a tooth moved out 1 mm over three turns has
  A 2π (c turns + ∫ offset dt).
- kernel: sweep_helix_radial, sharing sweep_helix's checks (helix_profile).
- doc: `ease_in`, `ease_out` (a length, or ISO 3508's standard 2.5 P or short 1.25 P) fade the
  groove over that length, its section moving out of the material as it turns (outward for a
  shaft, inward for a nut). ease_in is at the end the thread starts from. The runout lies past
  the full-form length: beyond a square stop, before a shoulder up to its clearance; at a free
  end inside the face. Runouts longer than the thread are refused. No end gets one by default,
  so saved threads keep their geometry.

The cut is the integral over the turn of the clipped section's 2π∫ r dA: engine test (2.5 P
and 1.25 P between two collars) within the sweep's wall tolerance times the runouts' wall area
(0.029 against a 0.036 mm³ bound), ladder 06/009 (004's shaft, a 2 P runout before the
shoulder) graded in closed form (measured 1.7e-6 off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-26 09:50:21 -04:00
parent 7bfc184e8e
commit d433ce2c9f
13 changed files with 368 additions and 41 deletions
+86 -29
View File
@@ -79,6 +79,9 @@ pub(super) struct ThreadAsk<'a> {
pub from: Option<Vec3>,
/// A modelled thread needs to know how each of its ends meets the face's ends.
pub modeled: bool,
/// 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],
}
/// How one end of a thread's length meets its face.
@@ -101,6 +104,8 @@ pub(super) struct ThreadOn {
/// How the thread's start and end meet the face (modelled threads only).
ends: [End; 2],
steep: SteepFlank,
/// Runout lengths (mm) at the thread's start and end along the axis.
ease: [f64; 2],
}
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
@@ -206,18 +211,20 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
}
Ok((a, b))
};
let (a, b) = match ask.extent {
Extent::Full => (lo, hi),
let from_side = |ends: &mut FaceEnds| start_side(ends, ask.from.map(along), along(p));
let (a, b, side) = match ask.extent {
Extent::Full => (lo, hi, ask.from.map(|q| usize::from((along(q) - lo).abs() > (hi - along(q)).abs())).unwrap_or(0)),
Extent::Length(l) => {
if !(l > 0.0) {
return Err(DocError::Invalid("thread length must be positive".into()));
}
if l >= hi - lo - pitch * 1e-3 {
// As long as the face (or longer): it runs the whole face from either end.
(lo, hi)
(lo, hi, ask.from.map(|q| usize::from((along(q) - lo).abs() > (hi - along(q)).abs())).unwrap_or(0))
} else {
let side = start_side(&mut ends, ask.from.map(along), along(p))?;
span(side, if side == 0 { lo + l } else { hi - l })?
let side = from_side(&mut ends)?;
let (a, b) = span(side, if side == 0 { lo + l } else { hi - l })?;
(a, b, side)
}
}
Extent::UpTo(pl) => {
@@ -227,13 +234,26 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
return Err(DocError::Invalid("the plane the thread runs up to is parallel to its axis".into()));
}
let to = n.dot(pl.origin - cyl.axis_point) / k;
span(start_side(&mut ends, ask.from.map(along), along(p))?, to)?
let side = from_side(&mut ends)?;
let (a, b) = span(side, to)?;
(a, b, side)
}
Extent::UpToPoint(q) => {
let side = from_side(&mut ends)?;
let (a, b) = span(side, along(q))?;
(a, b, side)
}
Extent::UpToPoint(q) => span(start_side(&mut ends, ask.from.map(along), along(p))?, along(q))?,
};
// How each end of the thread meets the face: at a face end, or within half a pitch of one
// (where the groove would reach past it), that end's free-end test decides.
let near = |at: f64, side: usize| if side == 0 { at - lo } else { hi - at } < pitch / 2.0;
// The runout at each end of the axis: ease in where the thread starts from.
let len = |(mm, pitches): (f64, f64)| mm + pitches * pitch;
let (e_in, e_out) = (len(ask.ease[0]), len(ask.ease[1]));
let ease = if side == 0 { [e_in, e_out] } else { [e_out, e_in] };
if ease.iter().any(|e| !(*e >= 0.0 && e.is_finite())) {
return Err(DocError::Invalid("a runout length must be positive".into()));
}
// How each end of the thread meets the face: at a face end, or within half a pitch (and its
// runout) of one, where the groove would reach past it, that end's free-end test decides.
let near = |at: f64, side: usize| if side == 0 { at - lo } else { hi - at } < pitch / 2.0 + ease.get(side).copied().unwrap_or(0.0);
let mut kind = |at: f64, side: usize| -> Result<End> {
Ok(if !ask.modeled || !near(at, side) {
End::Inside
@@ -261,7 +281,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
hand: ask.hand,
starts,
};
Ok(ThreadOn { info, cyl, ends, steep: ask.steep })
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease })
}
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
@@ -303,13 +323,15 @@ struct GrooveShape {
axis: Vec3,
pitch: f64,
turns: f64,
/// The section's move off the axis (turn, mm), piecewise linear: the runouts. Empty: none.
law: Vec<(f64, f64)>,
}
/// The helical spaces between the teeth of a modelled thread of the form's profile: cut from
/// the shaft (external) or the hole wall (internal), running from `start` to `end` along the
/// axis, one groove per start and hand. `radius` is the face's own radius, `face` its extent
/// along the axis and `ends` how the thread's two ends meet it.
fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2], steep: SteepFlank) -> Result<Vec<GrooveShape>> {
fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2], steep: SteepFlank, ease: [f64; 2]) -> Result<Vec<GrooveShape>> {
let (p, d, form) = (t.pitch, t.major_diameter, t.form);
let lead = p * f64::from(t.starts.max(1));
let len = t.end - t.start;
@@ -370,16 +392,23 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
// a closed one it stops with its section clear of the face's end (its centre as far inside
// as the section reaches that way), so it never cuts into the shoulder there; inside the
// face it stops square at the threaded length's end.
//
// A runout `e` at an end (ISO 3508) fades the groove out over `e`: its section moves out of
// the material (an external thread's away from the axis, past the surface; an internal
// one's towards it, into the hole) as it turns. It lies past the full-form length, up to the
// shoulder's clearance; at a free end, inside the face, the groove reaching nothing there.
let (w_lo, w_hi) = (lo0.max(lo1), hi0.max(hi1));
let s0 = match ends[0] {
End::Free => t.start - p,
End::Closed => t.start.max(face.0 + w_lo),
End::Inside => t.start,
let s0 = match (ends[0], ease[0] > 0.0) {
(End::Free, false) => t.start - p,
(End::Free, true) => t.start,
(End::Closed, _) => (t.start - ease[0]).max(face.0 + w_lo),
(End::Inside, _) => t.start - ease[0],
};
let s1 = match ends[1] {
End::Free => t.end + p,
End::Closed => t.end.min(face.1 - w_hi),
End::Inside => t.end,
let s1 = match (ends[1], ease[1] > 0.0) {
(End::Free, false) => t.end + p,
(End::Free, true) => t.end,
(End::Closed, _) => (t.end + ease[1]).min(face.1 - w_hi),
(End::Inside, _) => t.end + ease[1],
};
if !(s1 - s0 > p * 1e-3) {
return Err(DocError::Invalid(format!(
@@ -387,6 +416,23 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
show_mm(w_lo.max(w_hi))
)));
}
let turns = (s1 - s0) / lead;
// How far the section moves to leave the material, a little past it.
let out = if t.internal { -(r1 - radius + 0.05 * p) } else { radius - r0 + 0.05 * p };
if t.internal && r0 + out < 0.1 * p {
return Err(DocError::Invalid("the hole is too small for this thread's runout".into()));
}
let (r_in, r_out) = (ease[0] / lead, ease[1] / lead);
let mut law = Vec::new();
if r_in > 0.0 {
law.extend([(0.0, out), (r_in, 0.0)]);
}
if r_out > 0.0 {
law.extend([(turns - r_out, 0.0), (turns, out)]);
}
if r_in > 0.0 && r_out > 0.0 && !(turns - r_out > r_in + 1e-9) || r_in.max(r_out) > turns {
return Err(DocError::Invalid(format!("the runouts ({} and {} mm) are longer than the thread", show_mm(ease[0]), show_mm(ease[1]))));
}
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()))?;
@@ -397,7 +443,7 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
ThreadHand::Both => &[1.0, -1.0],
};
let n = t.starts.max(1);
let mut out = Vec::new();
let mut grooves = Vec::new();
for &h in hands {
for j in 0..n {
// Start j is the first turned a j-th of a turn on: the same helix a j-th of the
@@ -407,10 +453,10 @@ fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2],
// A left-handed groove: turning the same way about the reversed axis while rising
// along it (the screw motion about −k rising −lead per turn).
let (axis, pitch) = if h > 0.0 { (k, lead) } else { (-k, -lead) };
out.push(GrooveShape { plane: Plane { origin: base, x, y: k }, section: section.clone(), base, axis, pitch, turns: (s1 - s0) / lead });
grooves.push(GrooveShape { plane: Plane { origin: base, x, y: k }, section: section.clone(), base, axis, pitch, turns, law: law.clone() });
}
}
Ok(out)
Ok(grooves)
}
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
@@ -436,9 +482,6 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
else {
return Err(DocError::Invalid("not a thread".into()));
};
if ease_in.is_some() || ease_out.is_some() {
return Err(DocError::Invalid("a thread runout (ease in, ease out) is not built yet".into()));
}
if *tip != ThreadTip::None && !*modeled {
return Err(DocError::Invalid("a chamfered tip is cut only on a modelled thread".into()));
}
@@ -459,8 +502,22 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
extent,
from: from.map(|q| q.0),
modeled: *modeled,
ease: [(0.0, 0.0); 2],
};
let ThreadOn { info: t, cyl, ends, steep } = thread_on(st, f.id, face.0, &ask)?;
// 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_in.is_some() || ease_out.is_some()) && !*modeled {
return Err(DocError::Invalid("a runout is cut only on a modelled thread".into()));
}
let ask = ThreadAsk { ease: [runout(ease_in)?, runout(ease_out)?], ..ask };
let ThreadOn { info: t, cyl, ends, steep, ease } = thread_on(st, f.id, face.0, &ask)?;
if !*modeled {
st.threads.push(t);
return Ok(());
@@ -469,9 +526,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
// 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)? {
for g in thread_grooves(&t, cyl.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(&g.plane, &section, g.base, g.axis, g.pitch, g.turns)?);
grooves.push(kernel::sweep_helix_radial(&g.plane, &section, g.base, g.axis, g.pitch, g.turns, &g.law)?);
}
let tool = match grooves.as_slice() {
[one] => one.clone(),
+1 -1
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 (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table) (default: the standard size that fits the face, a shaft its major diameter, a hole its drill); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_in?, ease_out?: expr|standard|short (runout, not built yet); tip?: none|chamfer (modelled external threads: each free end the thread reaches is chamfered 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 down to the external minor diameter, the largest ISO 4753 allows); 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 first turned down to its major diameter over the threaded length"),
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table) (default: the standard size that fits the face, a shaft its major diameter, a hole its drill); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_in?, ease_out?: expr|standard|short (modelled threads: the groove fades out over this length, its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; ease_in at the end the thread starts from (`from`, or the start of its extent), ease_out at the other; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face); tip?: none|chamfer (modelled external threads: each free end the thread reaches is chamfered 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 down to the external minor diameter, the largest ISO 4753 allows); 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 first turned down to its major diameter over the threaded length"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
+63
View File
@@ -979,3 +979,66 @@ fn a_chamfered_tip_tapers_the_last_teeth() {
let e = s.execute("solid.thread", &json!({"face": [4.25, 0, 5], "designation": "M10", "tip": "chamfer", "modeled": true})).err().map(|e| e.to_string());
assert!(e.unwrap_or_default().contains("countersink"));
}
/// M10's groove section moved `d` out from the axis, clipped at the shaft's radius 5: the cut per
/// radian of turn, ∫ r w(r - d) dr from its moved root out to 5 (w the section's own width at
/// its radius, P/4 + 2 (ρ - root) tan 30°). A cubic in d until the root reaches the surface.
fn m10_cut_per_radian(d: f64) -> f64 {
let p = 1.5;
let root = 5.0 - 5.0 * p * 3f64.sqrt() / 16.0;
let q1 = 2.0 * (PI / 6.0).tan();
let q0 = p / 4.0 - q1 * root - q1 * d;
let a = root + d;
if a >= 5.0 {
return 0.0;
}
let f = |r: f64| q0 * r * r / 2.0 + q1 * r * r * r / 3.0;
f(5.0) - f(a)
}
/// The mean cut per radian over a runout whose offset runs linearly 0..h: Simpson on the cubic up
/// to where the root leaves the shaft (exact), nothing beyond.
fn m10_runout_mean(h: f64) -> f64 {
let p = 1.5;
let top = 5.0 * p * 3f64.sqrt() / 16.0;
let n = 64;
let step = top / n as f64;
let simpson: f64 = (0..n)
.map(|i| {
let (a, b) = (i as f64 * step, (i + 1) as f64 * step);
(m10_cut_per_radian(a) + 4.0 * m10_cut_per_radian((a + b) / 2.0) + m10_cut_per_radian(b)) * step / 6.0
})
.sum();
simpson / h
}
/// ISO 3508 runouts on a shaft between two collars: the groove fades out over 2.5 P (standard) at
/// the end it starts from and 1.25 P (short) at the other, its section moving out past the surface
/// as it turns, each reaching nothing where its section clears the collar. The cut is the
/// integral over the turn of the clipped section's 2π∫ r dA (Pappus with the section moving):
/// full turns at f(0), the runouts at the mean of f over the offsets they pass.
#[test]
fn a_runout_fades_the_groove_out_before_each_shoulder() {
let mut s = shaft_on_collar();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 35], "radius": 8, "height": 10, "operation": "join", "targets": ["Part"]}));
let before = exact_volume(&s, "Part");
run(&mut s, "solid.thread", json!({"face": [5, 0, 20], "designation": "M10", "ease_in": "standard", "ease_out": "short", "from": [0, 0, 10], "modeled": true}));
let st = s.model.state();
assert!(st.body("Part").unwrap().body.validity().is_empty());
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
let root = 5.0 - 5.0 * p * 3f64.sqrt() / 16.0;
let h = 5.0 - root + 0.05 * p;
let turns = (35.0 - 10.0 - 2.0 * w) / p;
let (r_in, r_out) = (2.5, 1.25);
let want = 2.0 * PI * (m10_cut_per_radian(0.0) * (turns - r_in - r_out) + (r_in + r_out) * m10_runout_mean(h));
let got = before - exact_volume(&s, "Part");
// Where the section moves, the sweep's walls lie within its tolerance (1e-4 P) of the true
// surfaces, not on them: up to that times the runouts' walls (about 64 mm² a turn, 3.75 turns),
// 0.036 mm³. Full-depth turns are exact radially (6e-4 mm³ here).
let bound = 1e-4 * p * 64.0 * (r_in + r_out);
assert!((got - want).abs() < bound, "{got} vs {want}");
// Longer runouts than the thread are refused.
let mut s = shaft_on_collar();
let e = s.execute("solid.thread", &json!({"face": [5, 0, 20], "designation": "M10", "ease_in": 14, "ease_out": 14, "modeled": true}));
assert!(e.err().map(|e| e.to_string()).unwrap_or_default().contains("longer than the thread"));
}
+23 -1
View File
@@ -15,6 +15,28 @@ fn rot(v: Vec3, k: Vec3, ang: f64) -> Vec3 {
/// its attitude relative to the axis, as a screw motion does: SMLib's screw sweep, one wall per
/// profile segment.
pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec3, pitch: f64, turns: f64) -> Result<Body> {
let (k, profile) = helix_profile(plane, region, axis_origin, axis, pitch, turns)?;
crate::smlib_sweeps::screw(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns)
}
/// [`sweep_helix`] with the profile moved off the axis as it turns: at turn t it first moves
/// `offset(t)` mm away from the axis (negative: towards it) in its half-plane, `offset` piecewise
/// linear through `law`'s (turn, offset) pairs, turns ascending within [0, `turns`]. A thread's
/// groove fading in or out over a runout: SMLib's radial screw sweep.
pub fn sweep_helix_radial(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec3, pitch: f64, turns: f64, law: &[(f64, f64)]) -> Result<Body> {
if law.is_empty() {
return sweep_helix(plane, region, axis_origin, axis, pitch, turns);
}
let (k, profile) = helix_profile(plane, region, axis_origin, axis, pitch, turns)?;
if !law.windows(2).all(|w| matches!(w, [a, b] if b.0 > a.0)) || law.iter().any(|(t, o)| !(t.is_finite() && o.is_finite() && *t >= 0.0 && *t <= turns)) {
return Err(KernelError::Invalid("helix: the radial law's turns must ascend within the sweep's turns".into()));
}
crate::smlib_sweeps::screw_radial(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns, law)
}
/// A helical sweep's axis and profile (the region's outer loop), checked: finite turns and
/// pitch, an area, and a profile that clears itself after one turn.
fn helix_profile(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec3, pitch: f64, turns: f64) -> Result<(Vec3, crate::numbered::NumberedLoop)> {
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("helix axis".into()))?;
if !(pitch.is_finite() && turns.is_finite() && turns > 0.0 && turns <= 200.0 && pitch.abs() < 1e7 && axis_origin.is_finite()) {
return Err(KernelError::Invalid("helix: turns must be in (0, 200] and the pitch finite".into()));
@@ -32,7 +54,7 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
if turns > 1.0 && pitch.abs() <= extent {
return Err(KernelError::Invalid(format!("the coil runs into itself: the pitch must exceed the section ({extent:.3} mm)")));
}
crate::smlib_sweeps::screw(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns)
Ok((k, profile))
}
/// A screw sweep of a convex polygon (in `plane`'s coordinates) as a closed triangle mesh,
+1 -1
View File
@@ -174,7 +174,7 @@ pub use fair::{
fair_faces, fair_net, fair_nurbs_curve, fair_nurbs_surface, fair_surface,
};
pub use fill::{PATCH_ANGLE_TOL_DEG, PatchReport, PatchSpec, patch_edges_crowned, patch_edges_report, patch_edges_with};
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh, sweep_helix_radial};
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies;
pub use intersection::{MeetingEdge, meeting_edges};
+13 -1
View File
@@ -71,7 +71,7 @@ pub(crate) struct Draft {
}
pub(crate) use imp::{
body_from_record, curve, extrude, face_section, loft, loft_rails, loft_to_point, path_curve, project_onto_face, revolve, screw, sphere, sweep,
body_from_record, curve, extrude, face_section, loft, loft_rails, loft_to_point, path_curve, project_onto_face, revolve, screw, screw_radial, sphere, sweep,
sweep_controlled,
};
@@ -1264,4 +1264,16 @@ mod imp {
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
with_history(body, &roles, pieces)
}
/// [`screw`] with the profile moved `offset(t)` mm off the axis at turn t (negative: towards
/// it), piecewise linear through `law`'s (turn, offset) pairs: a groove fading over a runout.
pub(crate) fn screw_radial(plane: &Plane, pieces: &[Piece], origin: Vec3, axis: Vec3, pitch: f64, turns: f64, law: &[(f64, f64)]) -> Result<Body> {
let c = curves(plane, pieces)?;
let tolerance = (pitch.abs() * 1e-4).clamp(1e-6, 1e-2);
let brep =
Brep::screw_sweep_radial(&c, p(origin), p(axis), pitch, turns, law, tolerance).map_err(|e| failed("screw sweep with a radial law", e))?;
let body = body_of(&brep, "screw sweep")?;
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
with_history(body, &roles, pieces)
}
}
+41 -1
View File
@@ -1,5 +1,5 @@
//! The sweeps block of `capi/include/smlib.h` (`smlib_extrude`, `smlib_revolve`, `smlib_sweep`,
//! `smlib_sweep_placed`, `smlib_project_curves`, `smlib_face_section` (with `smlib_curves_*`), `smlib_loft`, `smlib_loft_to_point`, `smlib_screw_sweep`,
//! `smlib_sweep_placed`, `smlib_project_curves`, `smlib_face_section` (with `smlib_curves_*`), `smlib_loft`, `smlib_loft_to_point`, `smlib_screw_sweep`, `smlib_screw_sweep_radial`,
//! `smlib_sweep_stations`, `smlib_planar_faces`; the fork's
//! `capi/src/smlib_sweeps.cpp`), declared by hand.
//! Part of the FFI boundary, like its parent: called only through [`crate::sweeps`], which builds
@@ -172,6 +172,20 @@ unsafe extern "C" {
tolerance: f64,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_screw_sweep_radial(
kinds: *const i64,
n_kinds: i64,
reals: *const f64,
n_reals: i64,
origin: *const f64,
direction: *const f64,
pitch: f64,
turns: f64,
law: *const f64,
n_law: i64,
tolerance: f64,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_sweep_stations(
kinds: *const i64,
n_kinds: i64,
@@ -324,6 +338,27 @@ pub fn screw_sweep(profile: &List, origin: &[f64; 3], direction: &[f64; 3], pitc
new_brep(|out| unsafe { smlib_screw_sweep(k, nk, r, nr, origin.as_ptr(), direction.as_ptr(), pitch, turns, tolerance, out) })
}
/// `law` holds (turn, offset) pairs.
#[cfg(feature = "native")]
pub fn screw_sweep_radial(
profile: &List,
origin: &[f64; 3],
direction: &[f64; 3],
pitch: f64,
turns: f64,
law: &[f64],
tolerance: f64,
) -> Result<Handle, i32> {
let (k, nk, r, nr) = raw(profile)?;
if !law.len().is_multiple_of(2) {
return Err(ERR_ARG);
}
let n = i64::try_from(law.len() / 2).map_err(|_| ERR_ARG)?;
new_brep(|out| unsafe {
smlib_screw_sweep_radial(k, nk, r, nr, origin.as_ptr(), direction.as_ptr(), pitch, turns, law.as_ptr(), n, tolerance, out)
})
}
/// `maps` holds 12 numbers per station (a matrix by columns, then a translation).
#[cfg(feature = "native")]
pub fn sweep_stations(profile: &List, maps: &[f64], ends: i32) -> Result<Handle, i32> {
@@ -385,6 +420,11 @@ pub fn screw_sweep(_: &List, _: &[f64; 3], _: &[f64; 3], _: f64, _: f64, _: f64)
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn screw_sweep_radial(_: &List, _: &[f64; 3], _: &[f64; 3], _: f64, _: f64, _: &[f64], _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn sweep_stations(_: &List, _: &[f64], _: i32) -> Result<Handle, i32> {
Err(NOT_BUILT)
+21
View File
@@ -317,6 +317,27 @@ impl Brep {
let l = List::of(profile)?;
ffi::sweeps::screw_sweep(&l.raw(), &origin, &direction, pitch, turns, tolerance).map(Brep).map_err(error)
}
/// [`screw_sweep`](Self::screw_sweep) with the profile moved off the axis as it turns: at turn
/// t every point of it first moves `offset(t)` mm away from the axis (negative: towards it)
/// in its half-plane, `offset` piecewise linear through the `(turn, offset)` pairs of `law`
/// (turns strictly ascending within [0, `turns`], held before the first and after the last).
/// A thread's groove fading in or out over a runout, or a taper. The profile must lie in a
/// half-plane bounded by the axis.
pub fn screw_sweep_radial(
profile: &[Curve],
origin: [f64; 3],
direction: [f64; 3],
pitch: f64,
turns: f64,
law: &[(f64, f64)],
tolerance: f64,
) -> Result<Brep> {
init()?;
let l = List::of(profile)?;
let flat: Vec<f64> = law.iter().flat_map(|(t, o)| [*t, *o]).collect();
ffi::sweeps::screw_sweep_radial(&l.raw(), &origin, &direction, pitch, turns, &flat, tolerance).map(Brep).map_err(error)
}
}
/// The error of a call that picks faces by points.
+4
View File
@@ -94,6 +94,10 @@ fn sweeps_lofts_and_screws() {
let tooth = polygon(&[[4.0, 0.0, 0.0], [4.4, 0.0, 0.0], [4.4, 0.0, 0.4], [4.0, 0.0, 0.4]]);
let b = Brep::screw_sweep(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, 1e-4).unwrap();
assert_close(solid_volume(&b, "screw"), 0.16 * TAU * 4.2 * 3.0, 1e-4, "screw sweep");
// The tooth moved out 1 mm over its three turns, linearly: A 2 pi (c turns + int offset dt).
let b = Brep::screw_sweep_radial(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, &[(0.0, 0.0), (3.0, 1.0)], 1e-4).unwrap();
assert_close(solid_volume(&b, "radial screw"), 0.16 * TAU * (4.2 * 3.0 + 1.5), 1e-4, "radial screw sweep");
assert_eq!(Brep::screw_sweep_radial(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, &[(2.0, 0.0), (1.0, 1.0)], 1e-4).err(), Some(Error::Argument));
}
/// `x` turned by `a` about z, then lifted `z`.
+19
View File
@@ -68,6 +68,25 @@ without one, at the end the `face` point lies within a third of the face of. A p
of a face whose ends are alike is refused, naming `from`. A length as long as the face runs the
whole face.
## Runout and tip
**Runout** (`ease_in`, `ease_out`: a length, `standard` = 2.5 P or `short` = 1.25 P, ISO 3508's
normal and short thread runouts x): the groove fades out over that length, its section moving
out of the material as it turns (an external thread's away from the axis, past the surface; a
nut's towards it, into the hole), linearly, by SMLib's radial screw sweep. `ease_in` is at the end
the thread starts from (`from`, or the start of its extent), `ease_out` at the other. The runout
lies past the full-form length: beyond a square stop, and before a shoulder up to its clearance
(the full form then ends a runout short of it); at a free end it lies inside the face, the groove
reaching nothing at the end. No end gets a runout unless asked. Where the section moves, the
sweep's walls are within its tolerance (1e-4 P) of the true surfaces; full-depth turns are exact
radially.
**Tip** (`tip: chamfer`, ISO 4753's chamfered end): at each free end of a modelled external
thread, after the groove, the material outside a cone `chamfer_angle` to the axis (45°) reaching
d_f = d − 2 `chamfer_depth` at the end (default the external minor diameter, the largest the
standard allows) is cut, the last teeth tapering into it. An internal thread takes the hole's
countersink instead.
## Sources
- ISO 68-1 (basic profile), ISO 261/262 (sizes), ISO 965-1 (multi-start designation `Ph`).
@@ -1,7 +1,23 @@
{
"sources": [],
"what": "an M10 thread that vanishes over a 2-pitch runout before a shoulder",
"source": "invariants",
"method": "a runout would let 004's thread vanish before the shoulder instead of stopping square",
"missing": "solid.thread has no runout (vanishing thread) length"
"what": "004's M10 thread run from the free top end, vanishing over a 3 mm (2 P) runout before the d16 shoulder",
"source": "analytic",
"method": "the shaft's lower end is a shoulder (the d16 collar lies past it, in the groove's way), so the groove (its runout included) stops with its widest part clear of it: its centre w = (P/4 + 2 (d/2 + 0.05P - r_root) tan30)/2 = 0.699551 mm above the shoulder (its half width at the tool's outer edge, 0.05 P past the crest), and the square stop inside material removes exactly as if the groove ended at its centre (see 003); the top end is free, the groove runs out into the air. The runout (ease_out, at the end away from `from`) moves the groove's section out of the shaft as it turns, linearly from h = d/2 - root + 0.05P at s = 10 + w to 0 at s + 3: the cut per radian is f(o) = int r w(r - o) dr from root + o to 5 (w the section's width at its own radius), the runout removes 2 pi (3/P) mean(f) (Simpson, exact on the cubic), the full-depth groove from s + 3 to the free top end 2 pi f(0) (35 - s - 3)/P (the overrun past the free end as in 001). V = pi 8^2 10 + pi 5^2 25 - both. Tolerance 0.01%: SMLib's exact radial screw sweep",
"body_count": 1,
"volume_mm3": 3673.442500807,
"bbox": {
"min": [
-8,
-8,
0
],
"max": [
8,
8,
35
]
},
"tolerance": {
"rel": 0.0001
}
}
@@ -1,3 +1,51 @@
{
"commands": []
"commands": [
{
"command": "solid.cylinder",
"params": {
"base": [
0,
0,
0
],
"radius": 8,
"height": 10,
"body_name": "Part"
}
},
{
"command": "solid.cylinder",
"params": {
"base": [
0,
0,
10
],
"radius": 5,
"height": 25,
"operation": "join",
"targets": [
"Part"
]
}
},
{
"command": "solid.thread",
"params": {
"face": [
5,
0,
25
],
"modeled": true,
"designation": "M10",
"ease_out": 3,
"from": [
0,
0,
35
]
}
}
]
}
+27 -2
View File
@@ -403,8 +403,33 @@ case(T6, "008-multi-start-thread", "a two-start M10 thread (lead 3, pitch 1.5) o
TMETH.replace("Tolerance 0.5%: solid.thread cuts faceted by design (chords within P/200 on the body, P/100 on the tool)", "Tolerance 0.01%: SMLib's exact screw sweep") +
"; two grooves of lead 2P, the second a half turn round from the first: every meridian half-plane holds the same sections a pitch apart as 001's one groove, "
"so the material fraction and the volume are 001's")
missing(T6, "009-thread-runout-length", "an M10 thread that vanishes over a 2-pitch runout before a shoulder", "solid.thread has no runout (vanishing thread) length",
"a runout would let 004's thread vanish before the shoulder instead of stopping square")
# The runout: the section moves out of the shaft as it turns, h = d/2 - root + 0.05 P at the shoulder
# end to 0 three mm (2 P) on; the cut per radian of turn is f(o) = int r w(r - o) dr from the moved root
# out to d/2, a cubic in the offset o until the root reaches the surface (Simpson exact).
def m10_f(o, P=1.5, d=10):
root = d / 2 - 5 * math.sqrt(3) / 16 * P
q1 = 2 * math.tan(PI / 6)
q0 = P / 4 - q1 * root - q1 * o
a = root + o
if a >= d / 2:
return 0.0
F = lambda r: q0 * r * r / 2 + q1 * r ** 3 / 3
return F(d / 2) - F(a)
P10 = 1.5
root10 = 5 - 5 * math.sqrt(3) / 16 * P10
h10 = 5 - root10 + 0.05 * P10
top10 = 5 - root10
mean10 = sum((m10_f(a) + 4 * m10_f((a + b) / 2) + m10_f(b)) * (b - a) / 6 for a, b in [(top10 * i / 64, top10 * (i + 1) / 64) for i in range(64)]) / h10
s_lo = 10 + w10
v9 = PI * 64 * 10 + PI * 25 * 25 - 2 * PI * (m10_f(0) * (35 - (s_lo + 3)) / P10 + 3 / P10 * mean10)
case(T6, "009-thread-runout-length", "004's M10 thread run from the free top end, vanishing over a 3 mm (2 P) runout before the d16 shoulder",
shaft + [thr([5, 0, 25], designation="M10", ease_out=3, **{"from": [0, 0, 35]})],
{"body_count": 1, "volume_mm3": v9, "bbox": {"min": [-8, -8, 0], "max": [8, 8, 35]}, "tolerance": {"rel": 1e-4}},
SHOULDER.replace("so the groove stops there with its widest part clear of it", "so the groove (its runout included) stops with its widest part clear of it") +
"The runout (ease_out, at the end away from `from`) moves the groove's section out of the shaft as it turns, linearly from h = d/2 - root + 0.05P at s = 10 + w to 0 at s + 3: "
"the cut per radian is f(o) = int r w(r - o) dr from root + o to 5 (w the section's width at its own radius), the runout removes 2 pi (3/P) mean(f) (Simpson, exact on the cubic), "
"the full-depth groove from s + 3 to the free top end 2 pi f(0) (35 - s - 3)/P (the overrun past the free end as in 001). "
"V = pi 8^2 10 + pi 5^2 25 - both. Tolerance 0.01%: SMLib's exact radial screw sweep")
case(T6, "010-part-threaded-boss", "the running part with a modelled M16 thread on its d16 boss",
[thr([23, 15, 25], designation="M16")], {"body_count": 1},
"the boss stands on the base (thread to a shoulder) and has an M5 hole in its top; modelled threads are faceted, so later tiers build on the part before this step and 11-cumulative-parts adds it last", base=C5, source="invariants")