diff --git a/crates/doc/src/eval_thread.rs b/crates/doc/src/eval_thread.rs index 619f24f7ce..9cac190a2d 100644 --- a/crates/doc/src/eval_thread.rs +++ b/crates/doc/src/eval_thread.rs @@ -429,8 +429,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, 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, 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, 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, depth: Option) -> Result> { + 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) +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 8a42bd5180..fcc7aab7a6 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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") diff --git a/crates/engine/src/thread_tests.rs b/crates/engine/src/thread_tests.rs index 8d2540e7b9..e54ed1a89c 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -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")); +} diff --git a/tests/kernel-cases/ladder/06-threads/017-external-m10-chamfered-tip/expected.json b/tests/kernel-cases/ladder/06-threads/017-external-m10-chamfered-tip/expected.json new file mode 100644 index 0000000000..8d128216ba --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/017-external-m10-chamfered-tip/expected.json @@ -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 + } +} diff --git a/tests/kernel-cases/ladder/06-threads/017-external-m10-chamfered-tip/recipe.json b/tests/kernel-cases/ladder/06-threads/017-external-m10-chamfered-tip/recipe.json new file mode 100644 index 0000000000..be33fac7f3 --- /dev/null +++ b/tests/kernel-cases/ladder/06-threads/017-external-m10-chamfered-tip/recipe.json @@ -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" + } + } + ] +} diff --git a/tests/kernel-cases/ladder/ladder.json b/tests/kernel-cases/ladder/ladder.json index 328acb90a3..00b9110b81 100644 --- a/tests/kernel-cases/ladder/ladder.json +++ b/tests/kernel-cases/ladder/ladder.json @@ -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" diff --git a/tools/kernel-ladder/cases.py b/tools/kernel-ladder/cases.py index a88cbb7c30..461348cf03 100644 --- a/tools/kernel-ladder/cases.py +++ b/tools/kernel-ladder/cases.py @@ -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)],