diff --git a/crates/doc/src/eval_thread.rs b/crates/doc/src/eval_thread.rs index f7cc184067..619f24f7ce 100644 --- a/crates/doc/src/eval_thread.rs +++ b/crates/doc/src/eval_thread.rs @@ -171,6 +171,12 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk) } }; let (major, pitch) = (size.major, size.pitch); + // A designation that gives the lead (M16xPh3P1.5, Tr40x14(P7)) gives the starts. + let named = (size.lead / pitch).round() as u32; + if ask.starts > 1 && named != starts { + 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 minor = 2.0 * form.groove(major, pitch, true).crest_r; let fits = if cyl.internal { @@ -436,9 +442,6 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, if *tip != ThreadTip::None { return Err(DocError::Invalid("a chamfered thread tip is not built yet".into())); } - if *starts > 1 { - return Err(DocError::Invalid("a thread with several starts is not built yet".into())); - } let extent = match (length, up_to) { (Some(e), _) => Extent::Length(val(vals, e, Kind::Length)?), (None, Some(crate::ThreadLimit::Face(q))) => Extent::UpToPoint(q.0), diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index e8d4b021f8..8a42bd5180 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; not built yet); 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 (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"), 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 3845a64570..8d2540e7b9 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -897,3 +897,30 @@ fn a_square_thread_is_refused() { let e = s.execute("solid.thread", &json!({"face": [10, 0, 10], "form": "square", "designation": "20x4"})).err().map(|e| e.to_string()); assert!(e.unwrap_or_default().contains("no current standard defines a square thread")); } + +/// Ladder 06/008: n starts are n grooves of lead n P, each a turn's n-th further round: over the +/// same length they take n x L/(nP) turns, the volume of one start of that pitch, and do not +/// meet. A designation that gives the lead gives the starts. +#[test] +fn a_multi_start_thread_takes_the_volume_of_one_start() { + let (d, p, len) = (10.0, 1.5, 30.0); + let want = len / p * ext_turn(d, p, d / 2.0); + for (starts, designation) in [(2, "M10"), (3, "M10"), (1, "M10xPh3P1.5")] { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 5, "height": len, "body_name": "Shaft"})); + let before = exact_volume(&s, "Shaft"); + run(&mut s, "solid.thread", json!({"face": [5, 0, 15], "designation": designation, "starts": starts, "modeled": true})); + let st = s.model.state(); + assert!(st.body("Shaft").unwrap().body.validity().is_empty(), "{designation} x{starts}"); + let got = before - exact_volume(&s, "Shaft"); + assert!((got - want).abs() < want * 1e-4, "{designation} x{starts}: removed {got}, one start {want}"); + } + // The cosmetic thread says it: two starts, lead 3. + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 20, "height": 40})); + let th = run(&mut s, "solid.thread", json!({"face": [20, 0, 20], "designation": "Tr40x14(P7)"})); + assert_eq!(th["thread"]["designation"], "Tr40x14(P7)", "{th}"); + assert_eq!(th["thread"]["starts"], 2, "{th}"); + 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")); +} diff --git a/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/expected.json b/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/expected.json index 08b368180a..f832979aa2 100644 --- a/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/expected.json +++ b/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/expected.json @@ -1,7 +1,11 @@ { "sources": [], - "what": "a two-start M10 thread (lead 3, pitch 1.5)", - "source": "invariants", - "method": "would be graded as 001 with the material fraction of two grooves per lead", - "missing": "solid.thread has no start count: multi-start threads" + "what": "a two-start M10 thread (lead 3, pitch 1.5) over a d10 x 30 cylinder", + "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; 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", + "body_count": 1, + "volume_mm3": 1954.24591725, + "tolerance": { + "rel": 0.0001 + } } diff --git a/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/recipe.json b/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/recipe.json index fe7861aa13..ba067bba34 100644 --- a/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/recipe.json +++ b/tests/kernel-cases/ladder/06-threads/008-multi-start-thread/recipe.json @@ -1,3 +1,30 @@ { - "commands": [] + "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", + "starts": 2 + } + } + ] } diff --git a/tools/kernel-ladder/cases.py b/tools/kernel-ladder/cases.py index 69bc11b8a9..a88cbb7c30 100644 --- a/tools/kernel-ladder/cases.py +++ b/tools/kernel-ladder/cases.py @@ -398,8 +398,11 @@ case(T6, "007-external-m10-at-filleted-shoulder", "a modelled M10 thread on a d1 "the fillet's ring: the corner square [5,6]x[10,11] less the quarter disc about (6,11), V_f = 2 pi (5.5 - pi/4 (6 - 4/(3 pi))), its centroid z from the same moments; " "the cylinder face starts at z=11, where the fillet meets it. " + SHOULDER.replace("lower end is a shoulder (the d16 collar", "lower end (z=11) is a shoulder (the fillet and the collar") + "V = pi 8^2 10 + V_f + pi 5^2 (1 + w) + 2 pi int_{11+w}^35 int r m(r) dr dz. Tolerance 0.05%: SMLib's exact screw sweep") -missing(T6, "008-multi-start-thread", "a two-start M10 thread (lead 3, pitch 1.5)", "solid.thread has no start count: multi-start threads", - "would be graded as 001 with the material fraction of two grooves per lead") +case(T6, "008-multi-start-thread", "a two-start M10 thread (lead 3, pitch 1.5) over a d10 x 30 cylinder", cylT + [thr([5, 0, 15], designation="M10", starts=2)], + {"body_count": 1, "volume_mm3": ext_volume(10, 1.5, [(0, 30, lambda z: 5)]), "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") + + "; 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") case(T6, "010-part-threaded-boss", "the running part with a modelled M16 thread on its d16 boss",