diff --git a/crates/doc/src/eval_thread.rs b/crates/doc/src/eval_thread.rs index 9cac190a2d..969c4250d8 100644 --- a/crates/doc/src/eval_thread.rs +++ b/crates/doc/src/eval_thread.rs @@ -79,6 +79,9 @@ pub(super) struct ThreadAsk<'a> { pub from: Option, /// 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 { 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> { +fn thread_grooves(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2], steep: SteepFlank, ease: [f64; 2]) -> Result> { 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, 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, 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| -> 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, 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, §ion, g.base, g.axis, g.pitch, g.turns)?); + grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?); } let tool = match grooves.as_slice() { [one] => one.clone(), diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index fcc7aab7a6..280b63d8f2 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 (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") diff --git a/crates/engine/src/thread_tests.rs b/crates/engine/src/thread_tests.rs index e54ed1a89c..bae96c7fd4 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -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")); +} diff --git a/crates/kernel/src/helix.rs b/crates/kernel/src/helix.rs index 9455336474..7224f10a8b 100644 --- a/crates/kernel/src/helix.rs +++ b/crates/kernel/src/helix.rs @@ -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 { + 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 { + 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, diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 69986c6b64..65e1bcea8a 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/smlib_sweeps.rs b/crates/kernel/src/smlib_sweeps.rs index 92e61f7a82..079b688590 100644 --- a/crates/kernel/src/smlib_sweeps.rs +++ b/crates/kernel/src/smlib_sweeps.rs @@ -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 { + 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) + } } diff --git a/crates/smlib/src/ffi/sweeps.rs b/crates/smlib/src/ffi/sweeps.rs index fe709ceed3..0a21a893eb 100644 --- a/crates/smlib/src/ffi/sweeps.rs +++ b/crates/smlib/src/ffi/sweeps.rs @@ -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 { + 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 { @@ -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 { + Err(NOT_BUILT) +} + #[cfg(not(feature = "native"))] pub fn sweep_stations(_: &List, _: &[f64], _: i32) -> Result { Err(NOT_BUILT) diff --git a/crates/smlib/src/sweeps.rs b/crates/smlib/src/sweeps.rs index 3e5c9fdb07..e13055242e 100644 --- a/crates/smlib/src/sweeps.rs +++ b/crates/smlib/src/sweeps.rs @@ -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 { + init()?; + let l = List::of(profile)?; + let flat: Vec = 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. diff --git a/crates/smlib/src/sweeps_tests.rs b/crates/smlib/src/sweeps_tests.rs index 5508d80884..e255b70fb0 100644 --- a/crates/smlib/src/sweeps_tests.rs +++ b/crates/smlib/src/sweeps_tests.rs @@ -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`. diff --git a/docs/threads.md b/docs/threads.md index 8bdfc5f6be..719f1dfebd 100644 --- a/docs/threads.md +++ b/docs/threads.md @@ -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`). diff --git a/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/expected.json b/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/expected.json index f5423cba32..969e7810d9 100644 --- a/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/expected.json +++ b/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/expected.json @@ -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 + } } diff --git a/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/recipe.json b/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/recipe.json index fe7861aa13..11772a75f5 100644 --- a/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/recipe.json +++ b/tests/kernel-cases/ladder/06-threads/009-thread-runout-length/recipe.json @@ -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 + ] + } + } + ] } diff --git a/tools/kernel-ladder/cases.py b/tools/kernel-ladder/cases.py index 461348cf03..36373997b5 100644 --- a/tools/kernel-ladder/cases.py +++ b/tools/kernel-ladder/cases.py @@ -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")