A modelled thread's chamfered tip (ISO 4753)

`tip: chamfer` cuts ISO 4753's chamfered end at each free end a modelled external thread
reaches, after the groove, so the last teeth taper into the cone instead of ending in a
ragged first turn: the material outside a cone `chamfer_angle` to the axis (default 45°)
reaching d_f = d - 2 `chamfer_depth` at the face's end (default the external minor diameter,
the largest the standard allows), a revolved ring cut by one boolean per end. Past the end the
ring keeps to the radii the groove's overrun already cuts. An internal thread is refused (its
entry takes the hole's countersink), as is a tip with no free end or on a cosmetic thread.

The cone is axisymmetric and cut after the screw-symmetric groove, so the volume is the stock's
chamfered section with the thread's material fraction: engine test (both ends 45°, and 30°
x 0.5 mm at a shaft's one free end above a collar) within 1e-4, ladder 06/017 (measured
2.4e-7 off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-26 09:33:00 -04:00
parent 3287bcba9e
commit 7bfc184e8e
7 changed files with 168 additions and 7 deletions
+60 -5
View File
@@ -429,8 +429,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
ease_in,
ease_out,
tip,
chamfer_angle: _,
chamfer_depth: _,
chamfer_angle,
chamfer_depth,
steep_flank,
} = &f.kind
else {
@@ -439,8 +439,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
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 {
return Err(DocError::Invalid("a chamfered thread tip 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()));
}
let extent = match (length, up_to) {
(Some(e), _) => Extent::Length(val(vals, e, Kind::Length)?),
@@ -482,9 +482,64 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
// 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 nb = Traced { body: nb, lineage };
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)? {
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);
let lineage = nb.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
nb = Traced { body: cut, lineage };
}
}
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at each of its free ends:
/// the material outside a cone `angle` to the axis (default 45°) that reaches the diameter
/// d_f = d - 2 `depth` at the face's end (default: the external thread's minor diameter, the
/// largest ISO 4753 allows), as a ring to cut after the groove, so the last teeth taper into it.
fn tip_cones(t: &ThreadInfo, radius: f64, ends: [End; 2], angle: Option<f64>, depth: Option<f64>) -> Result<Vec<Body>> {
if t.internal {
return Err(DocError::Invalid("a chamfered tip is for an external thread (ISO 4753); countersink the hole's entry instead".into()));
}
let g = t.form.groove(t.major_diameter, t.pitch, false);
let a = angle.unwrap_or(std::f64::consts::FRAC_PI_4);
if !(a > 1f64.to_radians() && a < 89f64.to_radians()) {
return Err(DocError::Invalid("the chamfer's angle to the axis must be between 1° and 89°".into()));
}
let r_top = radius.max(t.major_diameter / 2.0);
let depth = depth.unwrap_or(t.major_diameter / 2.0 - g.root_r);
let r_f = t.major_diameter / 2.0 - depth;
if !(depth > 0.0 && r_f > 1e-3) {
return Err(DocError::Invalid(format!("the chamfer's depth must be between 0 and {} mm", show_mm(t.major_diameter / 2.0))));
}
let k = *t.axis;
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?;
let (r_out, slope) = (r_top + 0.1 * t.pitch, a.tan());
// Past the end only the radii the groove's overrun already reaches (r >= d_f / 2), so
// nothing past a free end beyond the thread is touched.
let e = 0.5 * t.pitch;
let mut out = Vec::new();
for (side, at, out_dir) in [(0usize, t.start, -k), (1, t.end, k)] {
if ends.get(side) != Some(&End::Free) {
continue;
}
// In the plane: x radial, y outward along the axis from the end.
let plane = Plane { origin: t.axis_point + k * at, x: u, y: out_dir };
let pts = [Vec2::new(r_f, 0.0), Vec2::new(r_f, e), Vec2::new(r_out, e), Vec2::new(r_out, -(r_out - r_f) / slope)];
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: Vec::new() };
let mut ring = kernel::revolve(&plane, &[region], Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0), std::f64::consts::TAU)?;
out.push(ring.pop().ok_or_else(|| DocError::Invalid("chamfer".into()))?);
}
if out.is_empty() {
return Err(DocError::Invalid("a chamfered tip needs a free end of the thread (one with no material past it)".into()));
}
Ok(out)
}
+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 (with chamfer_angle?, chamfer_depth?; not built yet); 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 (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"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
+55
View File
@@ -924,3 +924,58 @@ fn a_multi_start_thread_takes_the_volume_of_one_start() {
let e = s.execute("solid.thread", &json!({"face": [20, 0, 20], "designation": "Tr40x14(P7)", "starts": 3})).err().map(|e| e.to_string());
assert!(e.unwrap_or_default().contains("has 2 start(s), not 3"));
}
/// The stock's volume after a modelled M10 thread, over sections z0..z1 whose outer radius is
/// `rmax(z)`: 2π∫∫ r m(r) dr dz, m the material fraction per pitch (1 under the root, 1 - w/P
/// in the groove's band). The groove's screw motion holds every meridian half-plane alike, and
/// an axisymmetric cut after it (a chamfer) only lowers rmax.
fn m10_stock(z0: f64, z1: f64, rmax: impl Fn(f64) -> f64) -> f64 {
let (d, p) = (10.0, 1.5);
let root = d / 2.0 - 5.0 * p * 3f64.sqrt() / 16.0;
let t = (PI / 6.0).tan();
// ∫_0^R r m(r) dr in closed form: R²/2 to the root, then m = a + b r (1 - (P/4 + 2 (r - root)
// tan 30°)/P) beyond it.
let ring = |rr: f64| {
let inner = rr.min(root);
let mut v = inner * inner / 2.0;
if rr > root {
let (a, b) = (1.0 - 0.25 + 2.0 * root * t / p, -2.0 * t / p);
let f = |r: f64| a * r * r / 2.0 + b * r * r * r / 3.0;
v += f(rr) - f(root);
}
v
};
let n = 20000;
(0..n).map(|i| z0 + (z1 - z0) * (i as f64 + 0.5) / n as f64).map(|z| 2.0 * PI * ring(rmax(z)) * (z1 - z0) / n as f64).sum()
}
/// ISO 4753's chamfered end on both free ends of an M10 stud: a 45° cone down to the minor
/// diameter, cut after the groove; the volume as the stock's chamfered section with the thread's
/// material fraction (as ladder 06/006).
#[test]
fn a_chamfered_tip_tapers_the_last_teeth() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 30, "body_name": "Stud"}));
run(&mut s, "solid.thread", json!({"face": [5, 0, 15], "designation": "M10", "tip": "chamfer", "modeled": true}));
let st = s.model.state();
assert!(st.body("Stud").unwrap().body.validity().is_empty());
let rf = 5.0 - 5.0 * 1.5 * 3f64.sqrt() / 16.0;
let want = m10_stock(0.0, 30.0, |z| 5f64.min(rf + z).min(rf + 30.0 - z));
let got = exact_volume(&s, "Stud");
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
// 30° to the axis and 0.5 mm deep, at the one free end of a shaft on a collar.
let mut s = shaft_on_collar();
run(&mut s, "solid.thread", json!({"face": [5, 0, 25], "designation": "M10", "tip": "chamfer", "chamfer_angle": "30 deg", "chamfer_depth": 0.5, "modeled": true}));
let w = ext_half_width(10.0, 1.5);
let rf = 4.5;
let slope = 30f64.to_radians().tan();
let want = PI * 64.0 * 10.0 + PI * 25.0 * w + m10_stock(10.0 + w, 35.0, |z| 5f64.min(rf + (35.0 - z) * slope));
let got = exact_volume(&s, "Part");
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
// Internal threads take a countersink instead.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 10, "body_name": "Nut"}));
run(&mut s, "solid.hole", json!({"position": [0, 0, 10], "diameter": 8.5}));
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"));
}
@@ -0,0 +1,11 @@
{
"sources": [],
"what": "001's modelled M10 thread with ISO 4753's chamfered end at both free ends: a 45 deg cone down to the minor diameter, cut after the groove",
"source": "analytic",
"method": "ISO 68-1 basic profile (60 deg, root flat P/4 at d - 5H/4 ... minor d1 = d - 1.0825 P, crest flat P/8): a helical groove of fixed section is a screw motion, so averaged over the turn every meridian half-plane holds the same section and V = 2 pi int int r m(r) dr dz over the stock's section, m the material fraction per pitch; the groove runs out past free ends. Tolerance 0.01%: SMLib's exact screw sweep; the chamfer is axisymmetric and cut after the groove, so it only lowers the stock's outer radius: r <= d_f/2 + distance from the end (45 deg), d_f the minor diameter d - 5H/4 = 8.376 (ISO 4753 chamfered end: the end's diameter at most the minor diameter)",
"body_count": 1,
"volume_mm3": 1947.833394596,
"tolerance": {
"rel": 0.0001
}
}
@@ -0,0 +1,30 @@
{
"commands": [
{
"command": "solid.cylinder",
"params": {
"base": [
0,
0,
0
],
"radius": 5,
"height": 30,
"body_name": "Part"
}
},
{
"command": "solid.thread",
"params": {
"face": [
5,
0,
15
],
"modeled": true,
"designation": "M10",
"tip": "chamfer"
}
}
]
}
+2 -1
View File
@@ -113,7 +113,8 @@
"ladder/06-threads/013-external-s20x4",
"ladder/06-threads/014-external-buttress-asme-1.5-8",
"ladder/06-threads/015-internal-tr20x4-nut",
"ladder/06-threads/016-external-m10-left-hand"
"ladder/06-threads/016-external-m10-left-hand",
"ladder/06-threads/017-external-m10-chamfered-tip"
],
"tier": "06-threads",
"what": "modelled threads"
+9
View File
@@ -454,6 +454,15 @@ case(T6, "016-external-m10-left-hand", "001's modelled M10 thread, left-handed:
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") +
"; a mirror image of 001's groove, so the same volume")
rf10 = 5 - 5 * math.sqrt(3) / 2 * 1.5 / 8
case(T6, "017-external-m10-chamfered-tip", "001's modelled M10 thread with ISO 4753's chamfered end at both free ends: a 45 deg cone down to the minor diameter, cut after the groove",
cylT + [thr([5, 0, 15], designation="M10", tip="chamfer")],
{"body_count": 1, "volume_mm3": ext_volume(10, 1.5, [(0, 5 - rf10, lambda z: rf10 + z), (5 - rf10, 25 + rf10, lambda z: 5), (25 + rf10, 30, lambda z: rf10 + 30 - z)]),
"tolerance": {"rel": 1e-4}},
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") +
"; the chamfer is axisymmetric and cut after the groove, so it only lowers the stock's outer radius: r <= d_f/2 + distance from the end (45 deg), d_f the minor diameter d - 5H/4 = 8.376 "
"(ISO 4753 chamfered end: the end's diameter at most the minor diameter)")
# ---------------------------------------------------------------- 07 fillets
F = lambda edges, r, **p: C("solid.fillet", edges=edges, radius=r, **p)
case(T7, "001-single-edge-r5", "an r5 round on the stock's top front edge", [F([[30, 0, 20]], 5)],