diff --git a/crates/doc/src/eval_thread.rs b/crates/doc/src/eval_thread.rs index 01fcd35b65..2c21c4743f 100644 --- a/crates/doc/src/eval_thread.rs +++ b/crates/doc/src/eval_thread.rs @@ -593,8 +593,12 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, /// 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. +/// d_f = d - 2 `depth` at the face's end, as a ring to cut after the groove, so the last teeth +/// taper into it. ISO 4753 bounds d_f by the external thread's minor diameter; the default is +/// that bound, d3 = d - 1.226869 P (ISO 724, the rounded root of the design profile). It lies +/// below the modelled basic root d1, so the cone crosses the root cylinder and leaves a whole +/// ring on the end; one ending on the root itself would pinch to nothing wherever the groove +/// reaches the end. ISO 4753 is for ISO metric threads: other forms give `depth`. 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())); @@ -605,7 +609,18 @@ fn tip_cones(t: &ThreadInfo, radius: f64, ends: [End; 2], angle: Option, de 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 depth = match (depth, t.form) { + (Some(d), _) => d, + // ISO 724: d3 = d1 - H/6 = d - 2 (17/24) H. + (None, ThreadForm::Metric) => 17.0 / 24.0 * t.pitch * 3f64.sqrt() / 2.0, + (None, form) => { + return Err(DocError::Invalid(format!( + "ISO 4753's chamfered end is for ISO metric threads: give a {} thread its chamfer depth (its root is {} mm deep)", + form.name(), + show_mm(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)))); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index e35fb138d6..20f82c39a8 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 (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 refused with the size to make it, never turned down"), + .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 for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); 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 refused with the size to make it, never turned down"), 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 62b768c734..3ef15e0922 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -863,17 +863,26 @@ fn m10_stock(z0: f64, z1: f64, rmax: impl Fn(f64) -> f64) -> f64 { (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). +/// ISO 4753's chamfered end on both free ends of an M10 stud: a 45° cone down to the external +/// minor diameter d3 (ISO 724), cut after the groove; the volume as the stock's chamfered section +/// with the thread's material fraction (as ladder 06/006). d3 lies below the modelled root, so +/// each end face is the whole disk of d3, with the cone a whole ring around it, not pinched +/// where the groove reaches the end. #[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 stud = &st.body("Stud").unwrap().body; + assert!(stud.validity().is_empty()); + let rf = 5.0 - 17.0 / 24.0 * 1.5 * 3f64.sqrt() / 2.0; + let ends: Vec = + stud.faces(1e-4).unwrap().into_iter().filter(|f| f.plane_normal.is_some_and(|n| n.z.abs() > 0.999)).map(|f| f.area).collect(); + assert_eq!(ends.len(), 2); + for a in ends { + assert!((a - PI * rf * rf).abs() < 1e-3 * a, "each end is a whole disk of d3: {a} vs {}", PI * rf * rf); + } 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}"); @@ -899,6 +908,16 @@ fn a_chamfered_tip_tapers_the_last_teeth() { .err() .map(|e| e.to_string()); assert!(e.unwrap_or_default().contains("countersink")); + // ISO 4753 is for ISO metric threads: another form names its chamfer depth. + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 40, "body_name": "Screw"})); + let e = s + .execute("solid.thread", &json!({"face": [10, 0, 20], "designation": "Tr20x4", "tip": "chamfer", "modeled": true})) + .err() + .map(|e| e.to_string()); + assert!(e.unwrap_or_default().contains("chamfer depth")); + run(&mut s, "solid.thread", json!({"face": [10, 0, 20], "designation": "Tr20x4", "tip": "chamfer", "chamfer_depth": 2.5, "modeled": true})); + assert!(s.model.state().body("Screw").unwrap().body.validity().is_empty()); } /// M10's groove section moved `d` out from the axis, clipped at the shaft's radius 5: the cut per diff --git a/docs/threads.md b/docs/threads.md index 719f1dfebd..3eff6192f8 100644 --- a/docs/threads.md +++ b/docs/threads.md @@ -83,9 +83,13 @@ 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. +d_f = d − 2 `chamfer_depth` at the end is cut, the last teeth tapering into it. ISO 4753 bounds +d_f by the minor diameter; the default is that bound, the external minor diameter d3 = d − +1.226869 P of ISO 724 (8.160 for M10), which lies below the modelled basic root d1. The cone +therefore crosses the root and leaves the end a whole disk of d3 inside a whole conical ring; a +cone ending on the root itself would pinch to nothing wherever the groove reaches the end. ISO +4753 is for ISO metric threads, so the other forms give `chamfer_depth`. An internal thread takes +the hole's countersink instead. ## Sources @@ -104,4 +108,9 @@ countersink instead. - ASME B1.9 (buttress inch): 7°/45°, basic height of engagement 0.6 P; the flat follows from the fundamental triangle H = P / (tan 7° + tan 45°) = 0.89064 P truncated equally at crest and root. - ISO 3508 (thread runouts) and ISO 4753 (ends of parts with external threads) for the runout and - the chamfered tip. + the chamfered tip. ISO 4753:2011, figure 1 b) (chamfered end CH; the public preview at + ): + the end's diameter "Maximum diameter at the minor thread diameter", the angle ≤ 50°, the + incomplete thread u ≤ 2 P. +- ISO 724 (external minor diameter d3 = d1 − H/6 = d − 1.226869 P, M10x1.5: 8.16), as given at + . 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 index 8d128216ba..4a1f808664 100644 --- 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 @@ -1,10 +1,10 @@ { "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", + "what": "001's modelled M10 thread with ISO 4753's chamfered end at both free ends: a 45 deg cone down to the external minor diameter d3, 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)", + "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 external minor diameter d3 = d - 1.226869 P = 8.160 (ISO 724; ISO 4753 chamfered end: the end's diameter at most the minor diameter), below the modelled root d1", "body_count": 1, - "volume_mm3": 1947.833394596, + "volume_mm3": 1945.354522563, "tolerance": { "rel": 0.0001 } diff --git a/tests/kernel-cases/ladder/golden.json b/tests/kernel-cases/ladder/golden.json index 53aa862d7d..9c872fdb9f 100644 --- a/tests/kernel-cases/ladder/golden.json +++ b/tests/kernel-cases/ladder/golden.json @@ -986,14 +986,14 @@ }, "ladder/06-threads/017-external-m10-chamfered-tip": { "measured": { - "area_mm2": 1500.75072007837, + "area_mm2": 1492.9858770288445, "body_count": 1, - "edges": 254, + "edges": 258, "faces": 88, - "vertices": 168, - "volume_mm3": 1947.8329205613231 + "vertices": 172, + "volume_mm3": 1945.3543465077544 }, - "seconds": 3.485166487, + "seconds": 3.293720875, "status": "pass" }, "ladder/06-threads/018-external-m10-both-hands": {