mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Threads in the standard forms, right- or left-handed or both
Only the ISO metric 60° thread existed. Forms now (crates/doc/src/thread_forms.rs, values and sources in docs/threads.md): ISO metric (ISO 68-1), trapezoidal (ISO 2904 design profile, ISO 2902 sizes), general purpose Acme (ASME B1.5), metric buttress 3°/30° (DIN 513), buttress inch 7°/45° (ASME B1.9 basic profile). Each has its groove as a straight-flanked polygon, its standard sizes (none for ASME buttress), its designations (M10, M16xPh3P1.5, Tr40x14(P7), S20x4, 1/2-10 ACME, 2.5-8 BUTT) and the size that fits a face. The square thread is refused: no current standard defines it. `hand: right | left | both`: a left-handed groove is the screw motion about the reversed axis rising along it; both cuts the two grooves, crossing, in one boolean. `steep_flank` turns a buttress. The design diameters are checked against the published tables (ISO 2904/DIN 103, DIN 513 via IS 4696-3); every form's cut against 2π∫ r w(r) dr per turn to 1e-4 in engine tests and to 1e-4 in ladder 06/011 to 016 (they measure within 5e-7). FeatureKind::Thread holds every option the command has (form, hand, starts, ease_in, ease_out, tip, chamfer_angle, chamfer_depth, steep_flank, all defaulted so saved threads load unchanged); starts, runouts and the chamfered tip are refused as not built yet. suggested_thread_in takes the form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -873,6 +873,31 @@ pub enum FeatureKind {
|
||||
/// Where the thread runs up to from the end it starts at, instead of a length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
up_to: Option<ThreadLimit>,
|
||||
/// The thread's form; `designation`'s prefix names it when this is the default.
|
||||
#[serde(default, skip_serializing_if = "crate::thread_forms::is_default")]
|
||||
form: crate::thread_forms::ThreadForm,
|
||||
#[serde(default, skip_serializing_if = "crate::thread_forms::is_default")]
|
||||
hand: crate::thread_forms::ThreadHand,
|
||||
/// Starts (1 to 8): the lead is starts × pitch (unless `designation` gives the lead).
|
||||
#[serde(default = "crate::thread_forms::one_start", skip_serializing_if = "crate::thread_forms::is_one_start")]
|
||||
starts: u32,
|
||||
/// Runout lengths at the thread's start and end (a groove that fades out).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ease_in: Option<ThreadRunout>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ease_out: Option<ThreadRunout>,
|
||||
/// The chamfered tip at the thread's free end (ISO 4753).
|
||||
#[serde(default, skip_serializing_if = "crate::thread_forms::is_default")]
|
||||
tip: crate::thread_forms::ThreadTip,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Chamfer angle")]
|
||||
chamfer_angle: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Chamfer depth")]
|
||||
chamfer_depth: Option<Expr>,
|
||||
/// Buttress forms: which way the steep flank of the screw's teeth faces.
|
||||
#[serde(default, skip_serializing_if = "crate::thread_forms::is_default")]
|
||||
steep_flank: crate::thread_forms::SteepFlank,
|
||||
},
|
||||
/// Ruled loft through profiles of several sketches, in order.
|
||||
Loft {
|
||||
@@ -1862,6 +1887,19 @@ pub enum ThreadLimit {
|
||||
Plane(PlaneRef),
|
||||
}
|
||||
|
||||
/// A thread's runout length at one end: a length, or ISO 3508's standard (2.5 P) or short
|
||||
/// (1.25 P) runout.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadRunout {
|
||||
Standard,
|
||||
Short,
|
||||
Length {
|
||||
#[walk(length = "Runout")]
|
||||
length: Expr,
|
||||
},
|
||||
}
|
||||
|
||||
/// What Extend extends up to instead of a distance.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
@@ -5883,8 +5883,8 @@ pub(crate) mod frame_member;
|
||||
mod surface;
|
||||
#[path = "eval_thread.rs"]
|
||||
mod thread;
|
||||
use thread::{thread_on, thread_on_impl};
|
||||
pub use thread::{ThreadInfo, metric_thread_sizes, parse_metric_thread, suggested_thread};
|
||||
use thread::thread_on;
|
||||
pub use thread::{ThreadInfo, metric_thread_sizes, parse_metric_thread, suggested_thread, suggested_thread_in};
|
||||
|
||||
/// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face
|
||||
/// there if it is planar and parallel (its plane), else where a ray from `centre` along the
|
||||
|
||||
+191
-149
@@ -1,7 +1,8 @@
|
||||
//! Threads: the ISO metric sizes, the cosmetic thread on a cylindrical face, and the modelled
|
||||
//! Threads: the cosmetic thread on a cylindrical face, and the modelled
|
||||
//! thread's groove cut along a helix.
|
||||
|
||||
use super::*;
|
||||
use crate::thread_forms::{SteepFlank, ThreadForm, ThreadHand, ThreadTip};
|
||||
|
||||
/// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -10,8 +11,6 @@ pub struct ThreadInfo {
|
||||
pub body: String,
|
||||
pub designation: String,
|
||||
pub major_diameter: f64,
|
||||
/// Radius of the actual cylindrical face when the cosmetic thread is evaluated.
|
||||
pub radius: f64,
|
||||
pub pitch: f64,
|
||||
pub internal: bool,
|
||||
pub axis_point: Vec3,
|
||||
@@ -19,76 +18,36 @@ pub struct ThreadInfo {
|
||||
/// Threaded part along the axis from `axis_point`.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
#[serde(default)]
|
||||
pub form: ThreadForm,
|
||||
#[serde(default)]
|
||||
pub hand: ThreadHand,
|
||||
#[serde(default = "crate::thread_forms::one_start")]
|
||||
pub starts: u32,
|
||||
}
|
||||
|
||||
/// ISO metric coarse threads: (nominal diameter, pitch).
|
||||
const ISO_COARSE: [(f64, f64); 22] = [
|
||||
(1.6, 0.35),
|
||||
(2.0, 0.4),
|
||||
(2.5, 0.45),
|
||||
(3.0, 0.5),
|
||||
(4.0, 0.7),
|
||||
(5.0, 0.8),
|
||||
(6.0, 1.0),
|
||||
(8.0, 1.25),
|
||||
(10.0, 1.5),
|
||||
(12.0, 1.75),
|
||||
(14.0, 2.0),
|
||||
(16.0, 2.0),
|
||||
(18.0, 2.5),
|
||||
(20.0, 2.5),
|
||||
(22.0, 2.5),
|
||||
(24.0, 3.0),
|
||||
(27.0, 3.0),
|
||||
(30.0, 3.5),
|
||||
(36.0, 4.0),
|
||||
(42.0, 4.5),
|
||||
(48.0, 5.0),
|
||||
(56.0, 5.5),
|
||||
];
|
||||
|
||||
/// Nominal diameters and coarse pitches (mm) used for automatic ISO metric thread sizing.
|
||||
/// Other diameter/pitch pairs can be supplied as explicit designations.
|
||||
pub fn metric_thread_sizes() -> &'static [(f64, f64)] {
|
||||
&ISO_COARSE
|
||||
&crate::thread_forms::ISO_COARSE
|
||||
}
|
||||
|
||||
/// "M8" or "M8x1" → (nominal diameter, pitch).
|
||||
pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> {
|
||||
let t = s.trim().to_ascii_uppercase();
|
||||
let body = t.strip_prefix('M')?;
|
||||
let (d, p) = match body.split_once(['X', '×']) {
|
||||
Some((d, p)) => (d.trim().parse::<f64>().ok()?, Some(p.trim().parse::<f64>().ok()?)),
|
||||
None => (body.trim().parse::<f64>().ok()?, None),
|
||||
};
|
||||
let pitch = match p {
|
||||
Some(p) => p,
|
||||
None => ISO_COARSE.iter().find(|(n, _)| (n - d).abs() < 1e-9)?.1,
|
||||
};
|
||||
(d.is_finite() && pitch.is_finite() && d > 0.0 && pitch > 0.0 && pitch < d).then_some((d, pitch))
|
||||
}
|
||||
|
||||
/// The metric thread a cylinder of this diameter takes: a shaft is the major diameter, a hole
|
||||
/// the tap drill (major − pitch).
|
||||
fn thread_for(diameter: f64, internal: bool) -> Option<(f64, f64)> {
|
||||
ISO_COARSE
|
||||
.iter()
|
||||
.map(|&(n, p)| (n, p, if internal { n - p } else { n }))
|
||||
.min_by(|a, b| (a.2 - diameter).abs().total_cmp(&(b.2 - diameter).abs()))
|
||||
.filter(|(_, _, want)| (want - diameter).abs() <= 0.15 * diameter)
|
||||
.map(|(n, p, _)| (n, p))
|
||||
}
|
||||
|
||||
pub(crate) fn format_thread(d: f64, p: f64) -> String {
|
||||
let coarse = ISO_COARSE.iter().any(|(n, q)| (n - d).abs() < 1e-9 && (q - p).abs() < 1e-9);
|
||||
let num = |x: f64| if (x - x.round()).abs() < 1e-9 { format!("{}", x.round()) } else { format!("{x}") };
|
||||
if coarse { format!("M{}", num(d)) } else { format!("M{}x{}", num(d), num(p)) }
|
||||
ThreadForm::Metric.parse(s, 1).ok().filter(|z| (z.lead - z.pitch).abs() < 1e-12).map(|z| (z.major, z.pitch))
|
||||
}
|
||||
|
||||
/// The automatic ISO metric designation for a cylindrical face, using the same fit check as
|
||||
/// a thread feature without an explicit size. This query does not change the model.
|
||||
pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result<String> {
|
||||
thread_on(st, 0, p, &ThreadAsk::default()).map(|t| t.info.designation)
|
||||
suggested_thread_in(st, p, ThreadForm::Metric)
|
||||
}
|
||||
|
||||
/// The standard designation of `form` that fits the cylindrical face at `p` (a shaft its major
|
||||
/// diameter, a hole its drill), with the same fit check as a thread feature without an explicit
|
||||
/// size. This query does not change the model.
|
||||
pub fn suggested_thread_in(st: &ModelState, p: Vec3, form: ThreadForm) -> Result<String> {
|
||||
thread_on(st, 0, p, &ThreadAsk { form: Some(form), ..Default::default() }).map(|t| t.info.designation)
|
||||
}
|
||||
|
||||
/// How far a thread runs along its face.
|
||||
@@ -108,7 +67,13 @@ pub(super) enum Extent {
|
||||
/// What a thread feature asks for: its size, how far it runs and from which end.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct ThreadAsk<'a> {
|
||||
/// The form; `None`: the one `designation`'s prefix names, else metric.
|
||||
pub form: Option<ThreadForm>,
|
||||
pub designation: Option<&'a str>,
|
||||
pub hand: ThreadHand,
|
||||
/// Starts; 0 is taken as 1.
|
||||
pub starts: u32,
|
||||
pub steep: SteepFlank,
|
||||
pub extent: Extent,
|
||||
/// A point near the end of the face the thread starts at.
|
||||
pub from: Option<Vec3>,
|
||||
@@ -135,6 +100,7 @@ pub(super) struct ThreadOn {
|
||||
cyl: kernel::CylinderFace,
|
||||
/// How the thread's start and end meet the face (modelled threads only).
|
||||
ends: [End; 2],
|
||||
steep: SteepFlank,
|
||||
}
|
||||
|
||||
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
|
||||
@@ -180,33 +146,50 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
.ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
|
||||
let mb = st.bodies.get(bi).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
||||
let dia = cyl.radius * 2.0;
|
||||
let (major, pitch) = match ask.designation {
|
||||
Some(s) => parse_metric_thread(s).ok_or_else(|| DocError::Invalid(format!("unknown thread `{s}` (ISO metric, like M8 or M8x1)")))?,
|
||||
None => thread_for(dia, cyl.internal).ok_or_else(|| DocError::Invalid(format!("no metric thread fits a {dia:.2} mm cylinder")))?,
|
||||
let form = match (ask.form, ask.designation.and_then(ThreadForm::of_designation)) {
|
||||
(Some(f), _) | (None, Some(f)) => f,
|
||||
(None, None) => ThreadForm::Metric,
|
||||
};
|
||||
let (major, pitch) = match &tapped_size {
|
||||
Some(size) => (size.major_diameter, size.pitch),
|
||||
None => match designation {
|
||||
Some(s) => parse_metric_thread(s).ok_or_else(|| DocError::Invalid(format!("unknown thread `{s}` (ISO metric, like M8 or M8x1)")))?,
|
||||
None => thread_for(dia, cyl.internal).ok_or_else(|| DocError::Invalid(format!("no metric thread fits a {dia:.2} mm cylinder")))?,
|
||||
},
|
||||
if let Some(why) = form.refused() {
|
||||
return Err(DocError::Invalid(why.into()));
|
||||
}
|
||||
let starts = ask.starts.max(1);
|
||||
if starts > 8 {
|
||||
return Err(DocError::Invalid("a thread has 1 to 8 starts".into()));
|
||||
}
|
||||
let size = match ask.designation {
|
||||
Some(s) => form.parse(s, starts).map_err(DocError::Invalid)?,
|
||||
None => {
|
||||
let (d, p) = form.fit(dia, cyl.internal).ok_or_else(|| {
|
||||
DocError::Invalid(if form.sizes().is_empty() {
|
||||
format!("{} threads have no size table: give the designation ({})", form.name(), form.format(50.8, 25.4 / 8.0, 25.4 / 8.0))
|
||||
} else {
|
||||
format!("no {} thread fits a {dia:.2} mm cylinder", form.name())
|
||||
})
|
||||
})?;
|
||||
crate::thread_forms::ThreadSize { form, major: d, pitch: p, lead: p * f64::from(starts) }
|
||||
}
|
||||
};
|
||||
let name = tapped_size.as_ref().map(|s| s.designation.clone()).unwrap_or_else(|| format_thread(major, pitch));
|
||||
let fits = if let Some(size) = tapped_size.as_ref().filter(|s| s.family != crate::TappedThreadFamily::Metric) {
|
||||
// Fit the recommended physical drill, without borrowing an ISO minor-diameter rule.
|
||||
cyl.internal && (size.tap_drill - dia).abs() < 0.15 * major
|
||||
} else if cyl.internal {
|
||||
(major - pitch * 1.0825 - dia).abs() < 0.2 * major || (major - pitch - dia).abs() < 0.15 * major
|
||||
let (major, pitch) = (size.major, size.pitch);
|
||||
let designation = form.format(major, pitch, size.lead);
|
||||
let minor = 2.0 * form.groove(major, pitch, true).crest_r;
|
||||
let fits = if cyl.internal {
|
||||
(minor - dia).abs() < 0.2 * major || (form.drill(major, pitch) - dia).abs() < 0.15 * major
|
||||
} else {
|
||||
(major - dia).abs() < 0.15 * major || (turned && dia > major)
|
||||
(major - dia).abs() < 0.15 * major
|
||||
};
|
||||
if !fits {
|
||||
return Err(DocError::Invalid(format!("an {} thread does not fit a {dia:.2} mm {}", name, if cyl.internal { "hole" } else { "shaft" })));
|
||||
return Err(DocError::Invalid(format!("an {designation} thread does not fit a {dia:.2} mm {}", if cyl.internal { "hole" } else { "shaft" })));
|
||||
}
|
||||
// The radii the groove cuts: from the minor diameter (ISO 68-1: 5H/8 under the major) to
|
||||
// the major, and to the face itself where it lies outside that.
|
||||
let minor_r = major / 2.0 - 5.0 * pitch * 3f64.sqrt() / 16.0;
|
||||
let band = (cyl.radius.min(minor_r), cyl.radius.max(major / 2.0));
|
||||
// The radii the groove cuts: from the external thread's minor diameter (a nut's: its minor)
|
||||
// to the major (a nut's root), and to the face itself where it lies outside that.
|
||||
let (r_lo, r_hi) = if cyl.internal {
|
||||
let g = form.groove(major, pitch, true);
|
||||
(g.crest_r, g.root_r)
|
||||
} else {
|
||||
(form.groove(major, pitch, false).root_r, major / 2.0)
|
||||
};
|
||||
let band = (cyl.radius.min(r_lo), cyl.radius.max(r_hi));
|
||||
let mut ends = FaceEnds { body: &mb.body, cyl: &cyl, band, pitch, free: [None; 2] };
|
||||
let (lo, hi) = (cyl.start, cyl.end);
|
||||
let along = |q: Vec3| (q - cyl.axis_point).dot(cyl.axis);
|
||||
@@ -260,17 +243,19 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
let info = ThreadInfo {
|
||||
feature,
|
||||
body: mb.name.clone(),
|
||||
designation: format_thread(major, pitch),
|
||||
designation,
|
||||
major_diameter: major,
|
||||
radius: cyl.radius,
|
||||
pitch,
|
||||
internal: cyl.internal,
|
||||
axis_point: cyl.axis_point,
|
||||
axis: cyl.axis,
|
||||
start: a,
|
||||
end: b,
|
||||
form,
|
||||
hand: ask.hand,
|
||||
starts,
|
||||
};
|
||||
Ok(ThreadOn { info, cyl, ends })
|
||||
Ok(ThreadOn { info, cyl, ends, steep: ask.steep })
|
||||
}
|
||||
|
||||
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
|
||||
@@ -302,141 +287,198 @@ fn start_side(ends: &mut FaceEnds, from: Option<f64>, at: f64) -> Result<usize>
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread's groove as a screw sweep: its trapezoid `section` in `plane` (an axial plane
|
||||
/// through `base`), carried `turns` turns about `axis` through `base`, rising `pitch` per turn.
|
||||
/// A thread's groove as a screw sweep: its `section` in `plane` (an axial plane through `base`),
|
||||
/// carried `turns` turns about `axis` through `base`, rising `pitch` per turn (negative: a
|
||||
/// left-handed groove).
|
||||
struct GrooveShape {
|
||||
plane: Plane,
|
||||
section: [Vec2; 4],
|
||||
section: Vec<Vec2>,
|
||||
base: Vec3,
|
||||
axis: Vec3,
|
||||
pitch: f64,
|
||||
turns: f64,
|
||||
}
|
||||
|
||||
/// The helical space between the teeth of a modelled ISO metric thread (ISO 68-1 basic
|
||||
/// profile, 60° flanks): cut from the shaft (external) or the hole wall (internal), running from
|
||||
/// `start` to `end` along the axis. `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_groove_shape(t: &ThreadInfo, radius: f64, face: (f64, f64), ends: [End; 2]) -> Result<GrooveShape> {
|
||||
let (p, d) = (t.pitch, t.major_diameter);
|
||||
let h = p * 3f64.sqrt() / 2.0;
|
||||
let slope = (std::f64::consts::PI / 6.0).tan();
|
||||
/// 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<Vec<GrooveShape>> {
|
||||
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;
|
||||
if !(p > 1e-3 && len > 1e-6) {
|
||||
return Err(DocError::Invalid("thread too short".into()));
|
||||
}
|
||||
if len / p > 190.0 {
|
||||
return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this pitch); use a cosmetic one", 190.0 * p)));
|
||||
if len / lead > 190.0 {
|
||||
return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this lead); use a cosmetic one", 190.0 * lead)));
|
||||
}
|
||||
// How deep the groove cuts into the material: from the hole wall out to the major diameter,
|
||||
// or from the shaft's surface in to the root. Next to nothing (a hole as wide as the thread,
|
||||
// #995) is refused: the faceted cut would leave the face faceted and unthreaded.
|
||||
let basic = 5.0 * h / 8.0;
|
||||
let depth = if t.internal { d / 2.0 - radius } else { radius - (d / 2.0 - basic) };
|
||||
let mut g = form.groove(d, p, t.internal);
|
||||
if steep == SteepFlank::Start {
|
||||
std::mem::swap(&mut g.flank_lo, &mut g.flank_hi);
|
||||
}
|
||||
let alt = |dia: f64| form.fit(dia, t.internal).map(|(n, q)| form.format(n, q, q)).filter(|a| *a != t.designation);
|
||||
// How deep the groove cuts into the material: from the hole wall out to the internal
|
||||
// thread's root, or from the shaft's surface in to the external thread's root. Next to
|
||||
// nothing (a hole as wide as the thread, #995) is refused: the cut would leave the face
|
||||
// unthreaded.
|
||||
let basic = g.basic_depth;
|
||||
let depth = if t.internal { g.root_r - radius } else { radius - g.root_r };
|
||||
let drill = show_mm(form.drill(d, p));
|
||||
if !(depth >= basic / 4.0) {
|
||||
let (m, dia) = (&t.designation, 2.0 * radius);
|
||||
let cut = format!("it would cut {:.2} mm of the thread's {basic:.2} mm depth", depth.max(0.0));
|
||||
let alt = thread_for(dia, t.internal).map(|(n, q)| format_thread(n, q)).filter(|a| a != m);
|
||||
let shown = show_mm(dia);
|
||||
return Err(DocError::Invalid(if t.internal {
|
||||
let alt = alt.map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm hole is too wide for a modelled {m} thread: {cut}; drill {} mm for {m}{alt}", show_mm(d - p))
|
||||
let alt = alt(dia).map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm hole is too wide for a modelled {m} thread: {cut}; drill {drill} mm for {m}{alt}")
|
||||
} else {
|
||||
let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
|
||||
let alt = alt(dia).map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm shaft is too thin for a modelled {m} thread: {cut}; make the shaft {} mm for {m}{alt}", show_mm(d))
|
||||
}));
|
||||
}
|
||||
// (radius, half width of the groove) at its two ends: a trapezoid in the axial plane.
|
||||
let (r0, w0, r1, w1) = if t.internal {
|
||||
// From inside the hole wall out to the major diameter, where the bolt's crest sits
|
||||
// (the root flat is P/8 wide).
|
||||
let r_in = radius.min(d / 2.0 - 5.0 * h / 8.0) - 0.1 * p;
|
||||
(r_in, (p / 8.0 + 2.0 * (d / 2.0 - r_in) * slope) / 2.0, d / 2.0, p / 16.0)
|
||||
} else {
|
||||
// From the root (5H/8 under the major diameter, flat P/4 wide) out past the surface.
|
||||
let r_root = d / 2.0 - 5.0 * h / 8.0;
|
||||
let r_out = radius.max(d / 2.0) + 0.05 * p;
|
||||
(r_root, p / 8.0, r_out, (p / 4.0 + 2.0 * (r_out - r_root) * slope) / 2.0)
|
||||
};
|
||||
if w0.max(w1) * 2.0 >= p * 0.98 {
|
||||
// The section's radial span: from the root out past the surface (external), or from inside
|
||||
// the hole wall out to the root (internal), so the cut leaves no skin.
|
||||
let (r0, r1) = if t.internal { (radius.min(g.crest_r) - 0.1 * p, g.root_r) } else { (g.root_r, radius.max(d / 2.0) + 0.05 * p) };
|
||||
let wide = if t.internal { r0 } else { r1 };
|
||||
if g.width(p, wide) >= p * 0.98 {
|
||||
// The groove would be wider than a pitch: the face is far inside the thread (a hole
|
||||
// narrower than its minor diameter, a shaft thicker than its major one, #2688).
|
||||
let (m, dia) = (&t.designation, 2.0 * radius);
|
||||
let alt = thread_for(dia, t.internal).map(|(n, q)| format_thread(n, q)).filter(|a| a != m);
|
||||
let shown = show_mm(dia);
|
||||
return Err(DocError::Invalid(if t.internal {
|
||||
let alt = alt.map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
|
||||
format!(
|
||||
"this {shown} mm hole is too narrow for a modelled {m} thread (its teeth would fill the hole); drill {} mm for {m}{alt}",
|
||||
show_mm(d - p)
|
||||
)
|
||||
let alt = alt(dia).map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm hole is too narrow for a modelled {m} thread (its teeth would fill the hole); drill {drill} mm for {m}{alt}")
|
||||
} else {
|
||||
let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
|
||||
let alt = alt(dia).map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm shaft is too thick for a modelled {m} thread; make the shaft {} mm for {m}{alt}", show_mm(d))
|
||||
}));
|
||||
}
|
||||
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 (lo0, hi0) = g.extents(p, r0);
|
||||
let (lo1, hi1) = g.extents(p, r1);
|
||||
let section = vec![Vec2::new(r0, -lo0), Vec2::new(r1, -lo1), Vec2::new(r1, hi1), Vec2::new(r0, hi0)];
|
||||
if !(r0.is_finite() && r1.is_finite() && r0 > 0.0) {
|
||||
return Err(DocError::Invalid("thread dimensions are out of range".into()));
|
||||
}
|
||||
// Past a free end of the face the groove runs out into the air, a pitch beyond it. Before
|
||||
// a closed one it stops with its widest part (half `w` either side of its centre) clear of
|
||||
// the face's end, so it never cuts into the shoulder there; inside the face it stops square
|
||||
// at the threaded length's end.
|
||||
let w = w0.max(w1);
|
||||
// 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.
|
||||
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),
|
||||
End::Closed => t.start.max(face.0 + w_lo),
|
||||
End::Inside => t.start,
|
||||
};
|
||||
let s1 = match ends[1] {
|
||||
End::Free => t.end + p,
|
||||
End::Closed => t.end.min(face.1 - w),
|
||||
End::Closed => t.end.min(face.1 - w_hi),
|
||||
End::Inside => t.end,
|
||||
};
|
||||
if !(s1 - s0 > p * 1e-3) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"the thread is too short to model between the shoulders: its groove needs {} mm clear of each",
|
||||
show_mm(w)
|
||||
show_mm(w_lo.max(w_hi))
|
||||
)));
|
||||
}
|
||||
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 base = t.axis_point + k * s0;
|
||||
let plane = Plane { origin: base, x: u, y: *k };
|
||||
let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)];
|
||||
if !(r0.is_finite() && r1.is_finite()) {
|
||||
return Err(DocError::Invalid("thread dimensions are too large".into()));
|
||||
let hands: &[f64] = match t.hand {
|
||||
ThreadHand::Right => &[1.0],
|
||||
ThreadHand::Left => &[-1.0],
|
||||
ThreadHand::Both => &[1.0, -1.0],
|
||||
};
|
||||
let n = t.starts.max(1);
|
||||
let mut out = 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
|
||||
// lead along.
|
||||
let a = std::f64::consts::TAU * f64::from(j) / f64::from(n);
|
||||
let x = u * a.cos() + k.cross(u) * a.sin();
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
Ok(GrooveShape { plane, section, base, axis: *k, pitch: p, turns: (s1 - s0) / p })
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
|
||||
/// one cut into its body.
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
let FeatureKind::Thread { face, designation, length, modeled, from, up_to } = &f.kind else {
|
||||
let FeatureKind::Thread {
|
||||
face,
|
||||
designation,
|
||||
length,
|
||||
modeled,
|
||||
from,
|
||||
up_to,
|
||||
form,
|
||||
hand,
|
||||
starts,
|
||||
ease_in,
|
||||
ease_out,
|
||||
tip,
|
||||
chamfer_angle: _,
|
||||
chamfer_depth: _,
|
||||
steep_flank,
|
||||
} = &f.kind
|
||||
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 {
|
||||
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),
|
||||
(None, Some(crate::ThreadLimit::Plane(pl))) => Extent::UpTo(doc.resolve_plane_in(vals, pl, 0, Some(st))?),
|
||||
(None, None) => Extent::Full,
|
||||
};
|
||||
let ask = ThreadAsk { designation: designation.as_deref(), extent, from: from.map(|q| q.0), modeled: *modeled };
|
||||
let ThreadOn { info: t, cyl, ends } = thread_on(st, f.id, face.0, &ask)?;
|
||||
// A form left at its default is the one the designation names (`Tr20x4`).
|
||||
let form = (*form != ThreadForm::Metric).then_some(*form);
|
||||
let ask = ThreadAsk {
|
||||
form,
|
||||
designation: designation.as_deref(),
|
||||
hand: *hand,
|
||||
starts: *starts,
|
||||
steep: *steep_flank,
|
||||
extent,
|
||||
from: from.map(|q| q.0),
|
||||
modeled: *modeled,
|
||||
};
|
||||
let ThreadOn { info: t, cyl, ends, steep } = thread_on(st, f.id, face.0, &ask)?;
|
||||
if !*modeled {
|
||||
st.threads.push(t);
|
||||
return Ok(());
|
||||
}
|
||||
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
// The exact groove: SMLib's screw sweep, cut by a B-rep boolean.
|
||||
let g = thread_groove_shape(&t, cyl.radius, (cyl.start, cyl.end), ends)?;
|
||||
let section = Region2 { outer: solidcraft_geom::Loop2::polygon(&g.section), holes: Vec::new() };
|
||||
let groove = kernel::sweep_helix(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns)?;
|
||||
let (nb, groove_faces) = (kernel::boolean(&input.body, &groove, BoolOp::Cut)?, groove.face_count());
|
||||
// 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)? {
|
||||
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)?);
|
||||
}
|
||||
let tool = match grooves.as_slice() {
|
||||
[one] => one.clone(),
|
||||
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
|
||||
};
|
||||
let (nb, groove_faces) = (kernel::boolean(&mb.body, &tool, BoolOp::Cut)?, tool.face_count());
|
||||
let nb = nb.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
|
||||
// The groove's faces have no role to name them by.
|
||||
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||||
let lineage = mb.traced().lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||||
let nb = Traced { body: nb, lineage };
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
|
||||
@@ -67,6 +67,7 @@ pub mod render_surface;
|
||||
pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
pub mod text_params;
|
||||
pub mod thread_forms;
|
||||
|
||||
pub use assembly::{
|
||||
IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, is_placement,
|
||||
@@ -79,7 +80,7 @@ pub use eval::{
|
||||
blend_target, curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at,
|
||||
import_body_names, map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
|
||||
revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread,
|
||||
tangent_face_chain, through_all_distance, world_state, world_state_reusing,
|
||||
suggested_thread_in, tangent_face_chain, through_all_distance, world_state, world_state_reusing,
|
||||
};
|
||||
pub use expr::Expr;
|
||||
pub use names::{BodyName, FeatureId, FeatureName, SketchId, copy_name};
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
//! Thread forms from the standards: each form's basic or design profile, its standard sizes and
|
||||
//! its designations. docs/threads.md gives the values and where they come from.
|
||||
//!
|
||||
//! A groove is the space between the teeth, in an axial half-plane: a polygon in (r, z), r from
|
||||
//! the axis, z along it, the groove's walls straight flanks (crest and root radii are not
|
||||
//! modelled, so every groove is a polygon the screw sweep carries exactly).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const INCH: f64 = 25.4;
|
||||
|
||||
/// A thread form.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadForm {
|
||||
/// ISO metric, 60° (ISO 68-1 basic profile, ISO 261/262 sizes): `M10`, `M10x1`.
|
||||
#[default]
|
||||
Metric,
|
||||
/// ISO metric trapezoidal, 30° (ISO 2904 design profile, ISO 2902 sizes): `Tr20x4`.
|
||||
Trapezoidal,
|
||||
/// General purpose Acme, 29° (ASME B1.5): `1/2-10 ACME`.
|
||||
Acme,
|
||||
/// Metric buttress (saw-tooth), 3°/30° (DIN 513-1): `S20x4`.
|
||||
Buttress,
|
||||
/// Buttress inch, 7°/45° (ASME B1.9 basic profile): `2.5-8 BUTT`.
|
||||
ButtressAsme,
|
||||
/// The square thread: no current standard defines it, so it is refused.
|
||||
Square,
|
||||
}
|
||||
|
||||
/// Which way a thread turns.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadHand {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
/// A right- and a left-handed groove crossing on one face (a reversing screw).
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Which way the steep (load) flank of a buttress screw's teeth faces along the thread: towards
|
||||
/// the thread's end (the way its axis points) or its start. A nut cut with the same setting mates.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SteepFlank {
|
||||
Start,
|
||||
#[default]
|
||||
End,
|
||||
}
|
||||
|
||||
/// The thread's free end: as cut, or chamfered (ISO 4753).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadTip {
|
||||
#[default]
|
||||
None,
|
||||
Chamfer,
|
||||
}
|
||||
|
||||
/// For `skip_serializing_if`: a field at its default is left out of the saved document.
|
||||
pub fn is_default<T: Default + PartialEq>(t: &T) -> bool {
|
||||
*t == T::default()
|
||||
}
|
||||
|
||||
pub fn one_start() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn is_one_start(n: &u32) -> bool {
|
||||
*n == 1
|
||||
}
|
||||
|
||||
/// A thread's size: nominal (major) diameter, pitch and lead, all in mm.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ThreadSize {
|
||||
pub form: ThreadForm,
|
||||
pub major: f64,
|
||||
pub pitch: f64,
|
||||
/// starts × pitch.
|
||||
pub lead: f64,
|
||||
}
|
||||
|
||||
/// ISO 261/262 coarse pitches: (nominal diameter, pitch).
|
||||
pub(crate) const ISO_COARSE: [(f64, f64); 22] = [
|
||||
(1.6, 0.35),
|
||||
(2.0, 0.4),
|
||||
(2.5, 0.45),
|
||||
(3.0, 0.5),
|
||||
(4.0, 0.7),
|
||||
(5.0, 0.8),
|
||||
(6.0, 1.0),
|
||||
(8.0, 1.25),
|
||||
(10.0, 1.5),
|
||||
(12.0, 1.75),
|
||||
(14.0, 2.0),
|
||||
(16.0, 2.0),
|
||||
(18.0, 2.5),
|
||||
(20.0, 2.5),
|
||||
(22.0, 2.5),
|
||||
(24.0, 3.0),
|
||||
(27.0, 3.0),
|
||||
(30.0, 3.5),
|
||||
(36.0, 4.0),
|
||||
(42.0, 4.5),
|
||||
(48.0, 5.0),
|
||||
(56.0, 5.5),
|
||||
];
|
||||
|
||||
/// The ISO 2902 diameter-pitch series' middle pitch per diameter, which ISO 2904 (trapezoidal)
|
||||
/// and DIN 513-2 (buttress, from 10 mm) share: (nominal diameter, pitch).
|
||||
const ISO_2902_MEDIUM: [(f64, f64); 41] = [
|
||||
(8.0, 1.5),
|
||||
(9.0, 2.0),
|
||||
(10.0, 2.0),
|
||||
(11.0, 2.0),
|
||||
(12.0, 3.0),
|
||||
(14.0, 3.0),
|
||||
(16.0, 4.0),
|
||||
(18.0, 4.0),
|
||||
(20.0, 4.0),
|
||||
(22.0, 5.0),
|
||||
(24.0, 5.0),
|
||||
(26.0, 5.0),
|
||||
(28.0, 5.0),
|
||||
(30.0, 6.0),
|
||||
(32.0, 6.0),
|
||||
(34.0, 6.0),
|
||||
(36.0, 6.0),
|
||||
(38.0, 7.0),
|
||||
(40.0, 7.0),
|
||||
(42.0, 7.0),
|
||||
(44.0, 7.0),
|
||||
(46.0, 8.0),
|
||||
(48.0, 8.0),
|
||||
(50.0, 8.0),
|
||||
(52.0, 8.0),
|
||||
(55.0, 9.0),
|
||||
(60.0, 9.0),
|
||||
(65.0, 10.0),
|
||||
(70.0, 10.0),
|
||||
(75.0, 10.0),
|
||||
(80.0, 10.0),
|
||||
(85.0, 12.0),
|
||||
(90.0, 12.0),
|
||||
(95.0, 12.0),
|
||||
(100.0, 12.0),
|
||||
(105.0, 12.0),
|
||||
(110.0, 12.0),
|
||||
(115.0, 14.0),
|
||||
(120.0, 14.0),
|
||||
(125.0, 14.0),
|
||||
(130.0, 14.0),
|
||||
];
|
||||
|
||||
/// ASME B1.5 general purpose Acme: (nominal diameter, threads per inch), in inches.
|
||||
const ACME_SIZES: [(&str, f64, f64); 23] = [
|
||||
("1/4", 0.25, 16.0),
|
||||
("5/16", 0.3125, 14.0),
|
||||
("3/8", 0.375, 12.0),
|
||||
("7/16", 0.4375, 12.0),
|
||||
("1/2", 0.5, 10.0),
|
||||
("5/8", 0.625, 8.0),
|
||||
("3/4", 0.75, 6.0),
|
||||
("7/8", 0.875, 6.0),
|
||||
("1", 1.0, 5.0),
|
||||
("1 1/8", 1.125, 5.0),
|
||||
("1 1/4", 1.25, 5.0),
|
||||
("1 3/8", 1.375, 4.0),
|
||||
("1 1/2", 1.5, 4.0),
|
||||
("1 3/4", 1.75, 4.0),
|
||||
("2", 2.0, 4.0),
|
||||
("2 1/4", 2.25, 3.0),
|
||||
("2 1/2", 2.5, 3.0),
|
||||
("2 3/4", 2.75, 3.0),
|
||||
("3", 3.0, 2.0),
|
||||
("3 1/2", 3.5, 2.0),
|
||||
("4", 4.0, 2.0),
|
||||
("4 1/2", 4.5, 2.0),
|
||||
("5", 5.0, 2.0),
|
||||
];
|
||||
|
||||
/// The radii and widths of a form's groove, external or internal, for one size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GrooveProfile {
|
||||
/// The flank angles from the radial direction of the groove's wall towards −z and towards +z.
|
||||
pub flank_lo: f64,
|
||||
pub flank_hi: f64,
|
||||
/// Where the groove's teeth have their crest: the major radius (external) or the minor one
|
||||
/// (internal), and the tooth's width there.
|
||||
pub crest_r: f64,
|
||||
pub crest_flat: f64,
|
||||
/// The groove's root: the minor radius (external) or the major one (internal).
|
||||
pub root_r: f64,
|
||||
/// The basic depth of engagement, for the "cuts next to nothing" check.
|
||||
pub basic_depth: f64,
|
||||
/// A nut's groove: its teeth's crests towards the axis.
|
||||
pub internal: bool,
|
||||
}
|
||||
|
||||
impl GrooveProfile {
|
||||
/// The groove's axial width at radius `r`: the pitch less the tooth there.
|
||||
pub fn width(&self, pitch: f64, r: f64) -> f64 {
|
||||
// How far `r` is from the crest towards the root (negative past the crest, in the air
|
||||
// the groove reaches into).
|
||||
let into = if self.internal { r - self.crest_r } else { self.crest_r - r };
|
||||
pitch - self.crest_flat - into * (self.flank_lo.tan() + self.flank_hi.tan())
|
||||
}
|
||||
/// The groove's (−z, +z) extents at radius `r`, its root flat centred on z = 0.
|
||||
pub fn extents(&self, pitch: f64, r: f64) -> (f64, f64) {
|
||||
let root = self.width(pitch, self.root_r) / 2.0;
|
||||
let d = (r - self.root_r).abs();
|
||||
(root + d * self.flank_lo.tan(), root + d * self.flank_hi.tan())
|
||||
}
|
||||
/// One turn of the groove over radii `a..b` (between root and crest): 2π∫ r w(r) dr.
|
||||
pub fn turn_volume(&self, pitch: f64, a: f64, b: f64) -> f64 {
|
||||
// w(r) = c0 + c1 r, linear in r.
|
||||
let (w0, w1) = (self.width(pitch, a), self.width(pitch, b));
|
||||
if (b - a).abs() < 1e-15 {
|
||||
return 0.0;
|
||||
}
|
||||
let c1 = (w1 - w0) / (b - a);
|
||||
let c0 = w0 - c1 * a;
|
||||
let f = |r: f64| c0 * r * r / 2.0 + c1 * r * r * r / 3.0;
|
||||
2.0 * std::f64::consts::PI * (f(b) - f(a))
|
||||
}
|
||||
}
|
||||
|
||||
impl ThreadForm {
|
||||
pub const ALL: [ThreadForm; 6] =
|
||||
[ThreadForm::Metric, ThreadForm::Trapezoidal, ThreadForm::Acme, ThreadForm::Buttress, ThreadForm::ButtressAsme, ThreadForm::Square];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ThreadForm::Metric => "metric",
|
||||
ThreadForm::Trapezoidal => "trapezoidal",
|
||||
ThreadForm::Acme => "acme",
|
||||
ThreadForm::Buttress => "buttress",
|
||||
ThreadForm::ButtressAsme => "buttress_asme",
|
||||
ThreadForm::Square => "square",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(s: &str) -> Option<ThreadForm> {
|
||||
ThreadForm::ALL.into_iter().find(|f| f.name().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
|
||||
/// The standard a form's profile and sizes come from.
|
||||
pub fn standard(self) -> &'static str {
|
||||
match self {
|
||||
ThreadForm::Metric => "ISO 68-1, ISO 261",
|
||||
ThreadForm::Trapezoidal => "ISO 2904, ISO 2902",
|
||||
ThreadForm::Acme => "ASME B1.5",
|
||||
ThreadForm::Buttress => "DIN 513-1, DIN 513-2",
|
||||
ThreadForm::ButtressAsme => "ASME B1.9",
|
||||
ThreadForm::Square => "none",
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a form cannot be cut, if it cannot.
|
||||
pub fn refused(self) -> Option<&'static str> {
|
||||
(self == ThreadForm::Square).then_some(
|
||||
"no current standard defines a square thread (ISO, ASME and DIN have none; Machinery's Handbook gives only historical proportions): use trapezoidal (ISO 2904) or acme (ASME B1.5)",
|
||||
)
|
||||
}
|
||||
|
||||
/// The form a designation's prefix names: `M` metric, `Tr` trapezoidal, `S` buttress, an inch
|
||||
/// size with `ACME` or `BUTT`.
|
||||
pub fn of_designation(s: &str) -> Option<ThreadForm> {
|
||||
let t = s.trim().to_ascii_uppercase();
|
||||
if t.contains("ACME") {
|
||||
Some(ThreadForm::Acme)
|
||||
} else if t.contains("BUTT") {
|
||||
Some(ThreadForm::ButtressAsme)
|
||||
} else if t.starts_with("TR") {
|
||||
Some(ThreadForm::Trapezoidal)
|
||||
} else if t.starts_with('S') {
|
||||
Some(ThreadForm::Buttress)
|
||||
} else if t.starts_with('M') {
|
||||
Some(ThreadForm::Metric)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard sizes, (nominal diameter, pitch) in mm; none for ASME buttress (its pitch is
|
||||
/// always given) and the square thread.
|
||||
pub fn sizes(self) -> Vec<(f64, f64)> {
|
||||
match self {
|
||||
ThreadForm::Metric => ISO_COARSE.to_vec(),
|
||||
ThreadForm::Trapezoidal => ISO_2902_MEDIUM.to_vec(),
|
||||
ThreadForm::Buttress => ISO_2902_MEDIUM.iter().copied().filter(|(d, _)| *d >= 10.0).collect(),
|
||||
ThreadForm::Acme => ACME_SIZES.iter().map(|(_, d, n)| (d * INCH, INCH / n)).collect(),
|
||||
ThreadForm::ButtressAsme | ThreadForm::Square => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard designations of [`sizes`](Self::sizes), in order.
|
||||
pub fn designations(self) -> Vec<String> {
|
||||
self.sizes().into_iter().map(|(d, p)| self.format(d, p, p)).collect()
|
||||
}
|
||||
|
||||
/// The size a designation names (`starts` starts unless the designation gives its lead).
|
||||
pub fn parse(self, s: &str, starts: u32) -> Result<ThreadSize, String> {
|
||||
if let Some(why) = self.refused() {
|
||||
return Err(why.into());
|
||||
}
|
||||
let bad = || format!("unknown {} thread `{s}` ({})", self.name(), self.example());
|
||||
if let Some(f) = ThreadForm::of_designation(s)
|
||||
&& f != self
|
||||
{
|
||||
return Err(format!("`{s}` is a {} designation, not {}", f.name(), self.name()));
|
||||
}
|
||||
let t = s.trim().to_ascii_uppercase().replace('×', "X").replace(' ', "");
|
||||
let n = f64::from(starts.max(1));
|
||||
let (major, pitch, lead) = match self {
|
||||
ThreadForm::Metric => {
|
||||
let body = t.strip_prefix('M').ok_or_else(bad)?;
|
||||
// M16xPh3P1.5 (ISO 965-1: lead 3, pitch 1.5), M10x1.5, M10.
|
||||
match body.split_once('X') {
|
||||
Some((d, rest)) => {
|
||||
let d = num(d).ok_or_else(bad)?;
|
||||
match rest.strip_prefix("PH").and_then(|r| r.split_once('P')) {
|
||||
Some((l, p)) => (d, num(p).ok_or_else(bad)?, num(l).ok_or_else(bad)?),
|
||||
None => {
|
||||
let p = num(rest).ok_or_else(bad)?;
|
||||
(d, p, p * n)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let d = num(body).ok_or_else(bad)?;
|
||||
let p = self.coarse(d).ok_or_else(|| format!("no coarse pitch for M{} (give it: M{}x1)", show(d), show(d)))?;
|
||||
(d, p, p * n)
|
||||
}
|
||||
}
|
||||
}
|
||||
ThreadForm::Trapezoidal | ThreadForm::Buttress => {
|
||||
let body = t.strip_prefix(if self == ThreadForm::Trapezoidal { "TR" } else { "S" }).ok_or_else(bad)?;
|
||||
// Tr40x7, Tr40x14(P7) (ISO 2903: lead 14, pitch 7), Tr40 (the standard pitch).
|
||||
match body.split_once('X') {
|
||||
Some((d, rest)) => {
|
||||
let d = num(d).ok_or_else(bad)?;
|
||||
match rest.split_once("(P") {
|
||||
Some((l, p)) => (d, num(p.strip_suffix(')').ok_or_else(bad)?).ok_or_else(bad)?, num(l).ok_or_else(bad)?),
|
||||
None => {
|
||||
let p = num(rest).ok_or_else(bad)?;
|
||||
(d, p, p * n)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let d = num(body).ok_or_else(bad)?;
|
||||
let p = self.coarse(d).ok_or_else(|| format!("no standard pitch for a {} mm {} thread: give it", show(d), self.name()))?;
|
||||
(d, p, p * n)
|
||||
}
|
||||
}
|
||||
}
|
||||
ThreadForm::Acme | ThreadForm::ButtressAsme => {
|
||||
// 1/2-10 ACME, 1 1/4-5, 2.5-8 BUTT (a class such as -2G or -2A is a tolerance, ignored).
|
||||
let body = t.trim_end_matches("ACME").trim_end_matches("PUSH-BUTT").trim_end_matches("PULL-BUTT").trim_end_matches("BUTT");
|
||||
let body = body.trim_end_matches('-');
|
||||
let mut parts = body.split('-');
|
||||
let d = parts.next().and_then(inches).ok_or_else(bad)?;
|
||||
let tpi = parts.next().and_then(num).ok_or_else(bad)?;
|
||||
if let Some(rest) = parts.next()
|
||||
&& !(rest.len() == 2 && rest.ends_with(['G', 'C', 'A', 'B']))
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let p = INCH / tpi;
|
||||
(d * INCH, p, p * n)
|
||||
}
|
||||
ThreadForm::Square => return Err(bad()),
|
||||
};
|
||||
if !(major.is_finite() && pitch.is_finite() && lead.is_finite() && major > 0.0 && pitch > 0.0 && pitch < major) {
|
||||
return Err(bad());
|
||||
}
|
||||
let k = lead / pitch;
|
||||
if !(k >= 1.0 - 1e-9 && (k - k.round()).abs() < 1e-6 && k.round() <= 8.0) {
|
||||
return Err(format!("the lead of `{s}` is not 1 to 8 times its pitch"));
|
||||
}
|
||||
Ok(ThreadSize { form: self, major, pitch, lead })
|
||||
}
|
||||
|
||||
fn example(self) -> &'static str {
|
||||
match self {
|
||||
ThreadForm::Metric => "like M8 or M8x1",
|
||||
ThreadForm::Trapezoidal => "like Tr20x4 or Tr40x14(P7)",
|
||||
ThreadForm::Acme => "like 1/2-10 ACME",
|
||||
ThreadForm::Buttress => "like S20x4 or S40x14(P7)",
|
||||
ThreadForm::ButtressAsme => "like 2.5-8 BUTT",
|
||||
ThreadForm::Square => "no standard",
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard pitch of a diameter in the form's table.
|
||||
fn coarse(self, d: f64) -> Option<f64> {
|
||||
self.sizes().into_iter().find(|(n, _)| (n - d).abs() < 1e-9).map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// A size's designation, in the form its standard writes it.
|
||||
pub fn format(self, d: f64, p: f64, lead: f64) -> String {
|
||||
let multi = (lead - p).abs() > 1e-9;
|
||||
match self {
|
||||
ThreadForm::Metric => {
|
||||
if multi {
|
||||
format!("M{}xPh{}P{}", show(d), show(lead), show(p))
|
||||
} else if self.coarse(d).is_some_and(|q| (q - p).abs() < 1e-9) {
|
||||
format!("M{}", show(d))
|
||||
} else {
|
||||
format!("M{}x{}", show(d), show(p))
|
||||
}
|
||||
}
|
||||
ThreadForm::Trapezoidal | ThreadForm::Buttress => {
|
||||
let pre = if self == ThreadForm::Trapezoidal { "Tr" } else { "S" };
|
||||
if multi { format!("{pre}{}x{}(P{})", show(d), show(lead), show(p)) } else { format!("{pre}{}x{}", show(d), show(p)) }
|
||||
}
|
||||
ThreadForm::Acme | ThreadForm::ButtressAsme => {
|
||||
// Acme sizes by their fractions (ASME B1.5's table), buttress in decimal inches.
|
||||
let di = d / INCH;
|
||||
let named = ACME_SIZES.iter().find(|(_, x, _)| self == ThreadForm::Acme && (x - di).abs() < 1e-9);
|
||||
let name = named.map(|(n, _, _)| n.to_string()).unwrap_or_else(|| show(di));
|
||||
let tpi = show(INCH / p);
|
||||
format!("{name}-{tpi} {}", if self == ThreadForm::Acme { "ACME" } else { "BUTT" })
|
||||
}
|
||||
ThreadForm::Square => format!("{}x{}", show(d), show(p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The minor diameter a hole for this size is drilled to: the tap drill (major − pitch) for
|
||||
/// metric, the internal thread's minor diameter for the others.
|
||||
pub fn drill(self, d: f64, p: f64) -> f64 {
|
||||
match self {
|
||||
ThreadForm::Metric => d - p,
|
||||
_ => 2.0 * self.groove(d, p, true).crest_r,
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard size a cylinder of this diameter takes: a shaft is the major diameter, a hole
|
||||
/// the drill. None when no size is within 15% (or the form has no size table).
|
||||
pub fn fit(self, diameter: f64, internal: bool) -> Option<(f64, f64)> {
|
||||
self.sizes()
|
||||
.into_iter()
|
||||
.map(|(n, p)| (n, p, if internal { self.drill(n, p) } else { n }))
|
||||
.min_by(|a, b| (a.2 - diameter).abs().total_cmp(&(b.2 - diameter).abs()))
|
||||
.filter(|(_, _, want)| (want - diameter).abs() <= 0.15 * diameter)
|
||||
.map(|(n, p, _)| (n, p))
|
||||
}
|
||||
|
||||
/// The groove of size (d, P): external (between a screw's teeth) or internal (a nut's).
|
||||
/// `flank_lo`/`flank_hi` are for a buttress with its steep flank towards the end (+z); the
|
||||
/// caller swaps them for [`SteepFlank::Start`].
|
||||
pub fn groove(self, d: f64, p: f64, internal: bool) -> GrooveProfile {
|
||||
let deg = |a: f64| a.to_radians();
|
||||
let half = |a: f64| (deg(a), deg(a));
|
||||
// (flanks, tooth crest flat, basic depth, external minor radius, internal minor radius,
|
||||
// internal major radius)
|
||||
let (fl, flat, depth, r3, r1, r4) = match self {
|
||||
ThreadForm::Metric | ThreadForm::Square => {
|
||||
// ISO 68-1: H = P sqrt3/2, basic depth 5H/8, flats P/8 at the major and P/4 at
|
||||
// the minor diameter.
|
||||
let h = p * 3f64.sqrt() / 2.0;
|
||||
let r_minor = d / 2.0 - 5.0 * h / 8.0;
|
||||
let flat = if internal { p / 4.0 } else { p / 8.0 };
|
||||
(half(30.0), flat, 5.0 * h / 8.0, r_minor, r_minor, d / 2.0)
|
||||
}
|
||||
ThreadForm::Trapezoidal => {
|
||||
// ISO 2904: 30°, H1 = P/2, flats P/2 - P/2 tan 15° (0.366 P) at both crests,
|
||||
// clearance ac on the external minor and internal major diameters.
|
||||
let ac = if p <= 1.5 {
|
||||
0.15
|
||||
} else if p <= 5.0 {
|
||||
0.25
|
||||
} else if p <= 12.0 {
|
||||
0.5
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let flat = p / 2.0 - p / 2.0 * deg(15.0).tan();
|
||||
(half(15.0), flat, p / 2.0, d / 2.0 - p / 2.0 - ac, d / 2.0 - p / 2.0, d / 2.0 + ac)
|
||||
}
|
||||
ThreadForm::Acme => {
|
||||
// ASME B1.5 general purpose: 29°, h = P/2, flat 0.3707 P, the external minor
|
||||
// 0.020" under the basic minor (0.010" finer than 10 per inch), the internal
|
||||
// major as far over the basic major.
|
||||
let a = if p >= INCH / 10.0 - 1e-9 { 0.020 * INCH } else { 0.010 * INCH };
|
||||
let flat = p / 2.0 - p / 2.0 * deg(14.5).tan();
|
||||
(half(14.5), flat, p / 2.0, d / 2.0 - p / 2.0 - a / 2.0, d / 2.0 - p / 2.0, d / 2.0 + a / 2.0)
|
||||
}
|
||||
ThreadForm::Buttress => {
|
||||
// DIN 513-1: 3° load flank, 30° clearance flank, H1 = 0.75 P, flats w = 0.26384 P,
|
||||
// the external minor h3 = 0.86777 P deep (clearance ac = 0.11777 P), the internal
|
||||
// thread from D1 = d - 1.5 P to D = d.
|
||||
let (t3, t30) = (deg(3.0).tan(), deg(30.0).tan());
|
||||
let flat = (p - 0.75 * p * (t3 + t30)) / 2.0;
|
||||
((deg(3.0), deg(30.0)), flat, 0.75 * p, d / 2.0 - 0.86777 * p, d / 2.0 - 0.75 * p, d / 2.0)
|
||||
}
|
||||
ThreadForm::ButtressAsme => {
|
||||
// ASME B1.9 basic profile: 7° load flank, 45° clearance flank, h = 0.6 P, flats
|
||||
// (P - 0.6 P (tan 7° + tan 45°))/2 = 0.16316 P, no allowances.
|
||||
let flat = (p - 0.6 * p * (deg(7.0).tan() + deg(45.0).tan())) / 2.0;
|
||||
((deg(7.0), deg(45.0)), flat, 0.6 * p, d / 2.0 - 0.6 * p, d / 2.0 - 0.6 * p, d / 2.0)
|
||||
}
|
||||
};
|
||||
// The steep flank faces +z on the screw's teeth: the external groove's −z wall, the
|
||||
// internal groove's +z wall (the nut's teeth face the other way).
|
||||
let (lo, hi) = if internal { (fl.1, fl.0) } else { fl };
|
||||
if internal {
|
||||
GrooveProfile { flank_lo: lo, flank_hi: hi, crest_r: r1, crest_flat: flat, root_r: r4, basic_depth: depth, internal }
|
||||
} else {
|
||||
GrooveProfile { flank_lo: lo, flank_hi: hi, crest_r: d / 2.0, crest_flat: flat, root_r: r3, basic_depth: depth, internal }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A number; fractions allowed (`1/2`).
|
||||
fn num(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let v = match s.split_once('/') {
|
||||
Some((a, b)) => a.parse::<f64>().ok()? / b.parse::<f64>().ok()?,
|
||||
None => s.parse::<f64>().ok()?,
|
||||
};
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// An inch size: `1`, `0.75`, `3/4`, `1 1/4` (spaces already gone: `11/4` is read as 1 1/4 only
|
||||
/// from the table's names).
|
||||
fn inches(s: &str) -> Option<f64> {
|
||||
if let Some((_, d, _)) = ACME_SIZES.iter().find(|(n, _, _)| n.replace(' ', "") == s) {
|
||||
return Some(*d);
|
||||
}
|
||||
num(s)
|
||||
}
|
||||
|
||||
fn show(v: f64) -> String {
|
||||
let s = format!("{v:.6}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn designations_read_and_write_back() {
|
||||
for (form, s, d, p, l) in [
|
||||
(ThreadForm::Metric, "M10", 10.0, 1.5, 1.5),
|
||||
(ThreadForm::Metric, "M10x1", 10.0, 1.0, 1.0),
|
||||
(ThreadForm::Metric, "M16xPh3P1.5", 16.0, 1.5, 3.0),
|
||||
(ThreadForm::Trapezoidal, "Tr20x4", 20.0, 4.0, 4.0),
|
||||
(ThreadForm::Trapezoidal, "Tr40x14(P7)", 40.0, 7.0, 14.0),
|
||||
(ThreadForm::Buttress, "S40x7", 40.0, 7.0, 7.0),
|
||||
(ThreadForm::Acme, "1/2-10 ACME", 12.7, 2.54, 2.54),
|
||||
(ThreadForm::Acme, "1 1/4-5 ACME", 31.75, 5.08, 5.08),
|
||||
(ThreadForm::ButtressAsme, "2.5-8 BUTT", 63.5, 3.175, 3.175),
|
||||
] {
|
||||
let z = form.parse(s, 1).unwrap();
|
||||
assert!((z.major - d).abs() < 1e-9 && (z.pitch - p).abs() < 1e-9 && (z.lead - l).abs() < 1e-9, "{s}: {z:?}");
|
||||
assert_eq!(form.format(z.major, z.pitch, z.lead), s);
|
||||
assert_eq!(ThreadForm::of_designation(s), Some(form), "{s}");
|
||||
}
|
||||
assert!(ThreadForm::Trapezoidal.parse("M10", 1).is_err());
|
||||
assert!(ThreadForm::Square.parse("20x4", 1).is_err());
|
||||
assert_eq!(ThreadForm::Trapezoidal.parse("Tr20x4", 2).unwrap().lead, 8.0);
|
||||
}
|
||||
|
||||
/// The design diameters against the standards' tables: ISO 2904 (Tr10x2: d3 7.5, D4 10.5, D1 8;
|
||||
/// Tr40x7: 32, 41, 33; Tr100x20: 78, 102, 80), DIN 513 (S10x2: d3 6.529, D1 7; S40x7: 27.851,
|
||||
/// 29.5), ISO 68-1 (M10: d1 = 8.376).
|
||||
#[test]
|
||||
fn design_diameters_match_the_tables() {
|
||||
let ext = |f: ThreadForm, d: f64, p: f64| 2.0 * f.groove(d, p, false).root_r;
|
||||
let int = |f: ThreadForm, d: f64, p: f64| (2.0 * f.groove(d, p, true).crest_r, 2.0 * f.groove(d, p, true).root_r);
|
||||
let tr = ThreadForm::Trapezoidal;
|
||||
for (d, p, d3, d4, d1) in [(10.0, 2.0, 7.5, 10.5, 8.0), (40.0, 7.0, 32.0, 41.0, 33.0), (100.0, 20.0, 78.0, 102.0, 80.0)] {
|
||||
assert!((ext(tr, d, p) - d3).abs() < 1e-9 && (int(tr, d, p).0 - d1).abs() < 1e-9 && (int(tr, d, p).1 - d4).abs() < 1e-9);
|
||||
}
|
||||
let s = ThreadForm::Buttress;
|
||||
for (d, p, d3, d1) in [(10.0, 2.0, 6.529, 7.0), (40.0, 7.0, 27.851, 29.5)] {
|
||||
assert!((ext(s, d, p) - d3).abs() < 5e-4 && (int(s, d, p).0 - d1).abs() < 1e-9, "S{d}x{p}");
|
||||
}
|
||||
assert!((ext(ThreadForm::Metric, 10.0, 1.5) - 8.376).abs() < 5e-4);
|
||||
// DIN 513 flat w = 0.26384 P.
|
||||
assert!((s.groove(10.0, 2.0, false).crest_flat - 0.26384 * 2.0).abs() < 1e-5);
|
||||
// ASME B1.9 flat 0.16316 P, B1.5 flat 0.3707 P.
|
||||
assert!((ThreadForm::ButtressAsme.groove(50.0, 4.0, false).crest_flat - 0.16316 * 4.0).abs() < 1e-4);
|
||||
assert!((ThreadForm::Acme.groove(25.4, 5.08, false).crest_flat - 0.3707 * 5.08).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// The basic profiles mesh: at the pitch line (half the basic depth in from the crest) groove
|
||||
/// and tooth are each half a pitch (symmetric forms), and an external tooth fits the internal
|
||||
/// groove of the same size.
|
||||
#[test]
|
||||
fn grooves_are_half_a_pitch_at_the_pitch_line() {
|
||||
for f in [ThreadForm::Metric, ThreadForm::Trapezoidal, ThreadForm::Acme] {
|
||||
let (d, p) = (20.0, 2.5);
|
||||
let g = f.groove(d, p, false);
|
||||
let r = d / 2.0 - match f {
|
||||
ThreadForm::Metric => 3.0 * p * 3f64.sqrt() / 16.0,
|
||||
_ => p / 4.0,
|
||||
};
|
||||
assert!((g.width(p, r) - p / 2.0).abs() < 1e-9, "{f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,10 @@ impl Walk for crate::document::ExtendMode {}
|
||||
impl Walk for crate::document::RuledMode {}
|
||||
impl Walk for crate::document::RuledCorners {}
|
||||
impl Walk for crate::document::ExtrudeConvention {}
|
||||
impl Walk for crate::thread_forms::ThreadForm {}
|
||||
impl Walk for crate::thread_forms::ThreadHand {}
|
||||
impl Walk for crate::thread_forms::ThreadTip {}
|
||||
impl Walk for crate::thread_forms::SteepFlank {}
|
||||
|
||||
/// A reference walks itself: a sketch, a feature or a body.
|
||||
macro_rules! reference {
|
||||
|
||||
@@ -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; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); 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; 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"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -2896,12 +2896,68 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.get("face")
|
||||
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
|
||||
.ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on a cylindrical face"))?;
|
||||
use solidcraft_doc::thread_forms::{SteepFlank, ThreadForm, ThreadHand, ThreadTip};
|
||||
let designation = str_(p, "designation").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
if let Some(t) = &designation
|
||||
&& solidcraft_doc::parse_metric_thread(t).is_none()
|
||||
{
|
||||
return Err(bad(cmd, unknown_metric_thread(t, "M8 or M8x1")));
|
||||
let form = match str_(p, "form") {
|
||||
Some(f) => ThreadForm::from_name(f)
|
||||
.ok_or_else(|| bad(cmd, format!("unknown thread form `{f}` (metric, trapezoidal, acme, buttress, buttress_asme or square)")))?,
|
||||
None => designation.as_deref().and_then(ThreadForm::of_designation).unwrap_or_default(),
|
||||
};
|
||||
if let Some(why) = form.refused() {
|
||||
return Err(bad(cmd, why));
|
||||
}
|
||||
let starts = match p.get("starts") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v.as_u64().filter(|n| (1..=8).contains(n)).ok_or_else(|| bad(cmd, "`starts` must be a whole number from 1 to 8"))? as u32,
|
||||
};
|
||||
if let Some(t) = &designation {
|
||||
form.parse(t, starts).map_err(|e| bad(cmd, e))?;
|
||||
}
|
||||
let hand = match str_(p, "hand") {
|
||||
None | Some("right") => ThreadHand::Right,
|
||||
Some("left") => ThreadHand::Left,
|
||||
Some("both") => ThreadHand::Both,
|
||||
Some(h) => return Err(bad(cmd, format!("unknown hand `{h}` (right, left or both)"))),
|
||||
};
|
||||
let steep_flank = match str_(p, "steep_flank") {
|
||||
None | Some("end") => SteepFlank::End,
|
||||
Some("start") => SteepFlank::Start,
|
||||
Some(h) => return Err(bad(cmd, format!("unknown steep_flank `{h}` (start or end)"))),
|
||||
};
|
||||
if p.get("steep_flank").is_some() && !matches!(form, ThreadForm::Buttress | ThreadForm::ButtressAsme) {
|
||||
return Err(bad(cmd, "`steep_flank` is for the buttress forms"));
|
||||
}
|
||||
let tip = match str_(p, "tip") {
|
||||
None | Some("none") => ThreadTip::None,
|
||||
Some("chamfer") => ThreadTip::Chamfer,
|
||||
Some(t) => return Err(bad(cmd, format!("unknown tip `{t}` (none or chamfer)"))),
|
||||
};
|
||||
let angled = |key: &str, kind: Kind| -> Result<Option<solidcraft_doc::Expr>> {
|
||||
let e = expr(p, key);
|
||||
if let Some(e) = &e {
|
||||
check_expr(s, e, kind, cmd, key)?;
|
||||
}
|
||||
Ok(e)
|
||||
};
|
||||
let chamfer_angle = angled("chamfer_angle", Kind::Angle)?;
|
||||
let chamfer_depth = angled("chamfer_depth", Kind::Length)?;
|
||||
if tip == ThreadTip::None && (chamfer_angle.is_some() || chamfer_depth.is_some()) {
|
||||
return Err(bad(cmd, "`chamfer_angle` and `chamfer_depth` go with `tip: chamfer`"));
|
||||
}
|
||||
let ease = |key: &str| -> Result<Option<solidcraft_doc::ThreadRunout>> {
|
||||
Ok(match p.get(key) {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(t)) if t == "standard" => Some(solidcraft_doc::ThreadRunout::Standard),
|
||||
Some(Value::String(t)) if t == "short" => Some(solidcraft_doc::ThreadRunout::Short),
|
||||
Some(Value::Number(n)) if n.as_f64() == Some(0.0) => None,
|
||||
Some(_) => {
|
||||
let e = expr(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a length, \"standard\" or \"short\"")))?;
|
||||
check_expr(s, &e, Kind::Length, cmd, key)?;
|
||||
Some(solidcraft_doc::ThreadRunout::Length { length: e })
|
||||
}
|
||||
})
|
||||
};
|
||||
let (ease_in, ease_out) = (ease("ease_in")?, ease("ease_out")?);
|
||||
let length = expr(p, "length");
|
||||
if let Some(l) = &length {
|
||||
check_expr(s, l, Kind::Length, cmd, "length")?;
|
||||
@@ -2926,13 +2982,33 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(Position(vec3(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y, z] near the end the thread starts at"))?)),
|
||||
};
|
||||
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
|
||||
// The coarse metric range bounds only a metric thread with no designation given.
|
||||
if designation.is_none()
|
||||
&& form == ThreadForm::Metric
|
||||
&& let Some(msg) = beyond_coarse_range(&s.model.state(), face)
|
||||
{
|
||||
return Err(bad(cmd, msg));
|
||||
}
|
||||
let handedness = thread_handedness(p, cmd)?;
|
||||
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled, handedness, from, up_to })?;
|
||||
let kind = FeatureKind::Thread {
|
||||
face: FacePick(face),
|
||||
designation,
|
||||
length,
|
||||
modeled,
|
||||
handedness,
|
||||
from,
|
||||
up_to,
|
||||
form,
|
||||
hand,
|
||||
starts,
|
||||
ease_in,
|
||||
ease_out,
|
||||
tip,
|
||||
chamfer_angle,
|
||||
chamfer_depth,
|
||||
steep_flank,
|
||||
};
|
||||
let v = add_feature(s, p, kind)?;
|
||||
let st = s.model.state();
|
||||
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
|
||||
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
|
||||
|
||||
@@ -782,3 +782,118 @@ fn a_blind_tapped_hole_stops_clear_of_its_bottom() {
|
||||
let got = exact_volume(&s, "Block");
|
||||
assert!((got - want).abs() < before * 1e-4, "{got} vs {want}");
|
||||
}
|
||||
|
||||
/// A form's groove width at radius r between its root and crest: the pitch less the tooth, the
|
||||
/// tooth `flat` wide at the crest radius `crest` and widening by tan a + tan b per unit of depth.
|
||||
fn form_turn(p: f64, flat: f64, crest: f64, a: f64, b: f64, r0: f64, r1: f64, internal: bool) -> f64 {
|
||||
let t = a.to_radians().tan() + b.to_radians().tan();
|
||||
let n = 2000;
|
||||
let w = |r: f64| p - flat - (if internal { r - crest } else { crest - r }) * t;
|
||||
(0..n).map(|i| r0 + (r1 - r0) * (i as f64 + 0.5) / n as f64).map(|r| 2.0 * PI * r * w(r) * (r1 - r0) / n as f64).sum()
|
||||
}
|
||||
|
||||
/// Every standard form cut into a shaft of its major diameter over six turns, right- and
|
||||
/// left-handed: the groove takes 2π∫ r w(r) dr per turn (Pappus on the section, screw motion),
|
||||
/// w from the standard's profile, from its external minor diameter to the shaft's surface.
|
||||
#[test]
|
||||
fn every_form_cuts_its_standard_profile() {
|
||||
let inch = 25.4;
|
||||
// (form, designation, d, P, crest flat, flanks, external minor radius)
|
||||
let tr_ac = 0.25;
|
||||
let acme_a = 0.020 * inch;
|
||||
let t3 = 3f64.to_radians().tan() + 30f64.to_radians().tan();
|
||||
let t7 = 7f64.to_radians().tan() + 1.0;
|
||||
let cases = [
|
||||
("trapezoidal", "Tr20x4", 20.0, 4.0, 2.0 * (1.0 - 15f64.to_radians().tan()), (15.0, 15.0), 10.0 - 2.0 - tr_ac),
|
||||
("acme", "3/4-6 ACME", 0.75 * inch, inch / 6.0, inch / 12.0 * (1.0 - 14.5f64.to_radians().tan()), (14.5, 14.5), 0.375 * inch - inch / 12.0 - acme_a / 2.0),
|
||||
("buttress", "S20x4", 20.0, 4.0, (4.0 - 3.0 * t3) / 2.0, (3.0, 30.0), 10.0 - 0.86777 * 4.0),
|
||||
("buttress_asme", "1.5-8 BUTT", 1.5 * inch, inch / 8.0, (inch / 8.0 - 0.6 * inch / 8.0 * t7) / 2.0, (7.0, 45.0), 0.75 * inch - 0.6 * inch / 8.0),
|
||||
];
|
||||
for (form, designation, d, p, flat, (a, b), r3) in cases {
|
||||
let mut volumes = Vec::new();
|
||||
for hand in ["right", "left"] {
|
||||
let mut s = Session::default();
|
||||
let len = 6.0 * p;
|
||||
run(&mut s, "solid.cylinder", json!({"radius": d / 2.0, "height": len, "body_name": "Shaft"}));
|
||||
let before = exact_volume(&s, "Shaft");
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [d / 2.0, 0, len / 2.0], "form": form, "designation": designation, "hand": hand, "modeled": true}));
|
||||
assert!(th["thread"].is_null(), "{th}");
|
||||
let st = s.model.state();
|
||||
assert!(st.body("Shaft").unwrap().body.validity().is_empty(), "{designation} {hand}");
|
||||
let want = 6.0 * form_turn(p, flat, d / 2.0, a, b, r3, d / 2.0, false);
|
||||
let got = before - exact_volume(&s, "Shaft");
|
||||
assert!((got - want).abs() < want * 1e-4, "{designation} {hand}: removed {got}, the standard's profile {want}");
|
||||
volumes.push(got);
|
||||
}
|
||||
assert!((volumes[0] - volumes[1]).abs() < volumes[0] * 1e-5, "{designation}: right {} left {}", volumes[0], volumes[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left-handed groove turns the other way: on the helix z = P θ / 2π (right-handed) the shaft
|
||||
/// is cut once a turn, on z = -P θ / 2π it is cut all along. Both: a right- and a left-handed
|
||||
/// groove, crossing.
|
||||
#[test]
|
||||
fn a_left_handed_thread_turns_the_other_way() {
|
||||
let (d, p, len) = (10.0, 1.5, 9.0);
|
||||
let r = (d / 2.0 + d / 2.0 - 5.0 * p * 3f64.sqrt() / 16.0) / 2.0;
|
||||
let on = |s: &Session, sign: f64| -> usize {
|
||||
let st = s.model.state();
|
||||
let body = &st.body("Shaft").unwrap().body;
|
||||
// Along each helix sense from every groove point on the z = 4.5 circle, count how often
|
||||
// the line stays in the groove: a groove lies on one of them.
|
||||
(0..36)
|
||||
.filter(|i| {
|
||||
let a0 = *i as f64 * 10f64.to_radians();
|
||||
let z0 = 4.5;
|
||||
let probe = |a: f64| Vec3::new(r * (a0 + a).cos(), r * (a0 + a).sin(), z0 + sign * p * a / std::f64::consts::TAU);
|
||||
!body.contains(probe(0.0)) && (1..12).all(|k| !body.contains(probe(k as f64 * 0.5)))
|
||||
})
|
||||
.count()
|
||||
};
|
||||
let mut volumes = Vec::new();
|
||||
for hand in ["right", "left", "both"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": d / 2.0, "height": len, "body_name": "Shaft"}));
|
||||
let before = exact_volume(&s, "Shaft");
|
||||
run(&mut s, "solid.thread", json!({"face": [5, 0, 4.5], "designation": "M10", "hand": hand, "modeled": true}));
|
||||
assert!(s.model.state().body("Shaft").unwrap().body.validity().is_empty(), "{hand}");
|
||||
let (right, left) = (on(&s, 1.0), on(&s, -1.0));
|
||||
match hand {
|
||||
"right" => assert!(right > 0 && left == 0, "right: {right} {left}"),
|
||||
"left" => assert!(left > 0 && right == 0, "left: {right} {left}"),
|
||||
_ => assert!(left > 0 && right > 0, "both: {right} {left}"),
|
||||
}
|
||||
volumes.push(before - exact_volume(&s, "Shaft"));
|
||||
}
|
||||
let want = 6.0 * ext_turn(d, p, d / 2.0);
|
||||
assert!((volumes[0] - want).abs() < want * 1e-4 && (volumes[1] - want).abs() < want * 1e-4, "{volumes:?} vs {want}");
|
||||
// Crossing, the two grooves share some of their volume.
|
||||
assert!(volumes[2] > want * 1.2 && volumes[2] < 2.0 * want, "{volumes:?}");
|
||||
}
|
||||
|
||||
/// A trapezoidal nut: the groove from the drilled minor diameter D1 = d - P out to the
|
||||
/// internal root D4 = d + 2 ac (ISO 2904), 2π∫ r w(r) dr per turn.
|
||||
#[test]
|
||||
fn an_internal_trapezoidal_thread_cuts_out_to_its_root() {
|
||||
let (d, p, ac) = (20.0, 4.0, 0.25);
|
||||
let d1 = d - p;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": 6.0 * p, "body_name": "Nut"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -1], "radius": d1 / 2.0, "height": 6.0 * p + 2.0, "body_name": "Bore"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Nut", "tools": ["Bore"], "operation": "cut"}));
|
||||
let before = exact_volume(&s, "Nut");
|
||||
run(&mut s, "solid.thread", json!({"face": [d1 / 2.0, 0, 3.0 * p], "designation": "Tr20x4", "modeled": true}));
|
||||
let flat = p / 2.0 * (1.0 - 15f64.to_radians().tan());
|
||||
let want = 6.0 * form_turn(p, flat, d1 / 2.0, 15.0, 15.0, d1 / 2.0, d / 2.0 + ac, true);
|
||||
let got = before - exact_volume(&s, "Nut");
|
||||
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
|
||||
}
|
||||
|
||||
/// No current standard defines a square thread: it is refused, saying what to use.
|
||||
#[test]
|
||||
fn a_square_thread_is_refused() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20}));
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Threads
|
||||
|
||||
`solid.thread` puts a thread on a cylindrical face: cosmetic (an annotation, `model.threads`
|
||||
lists it) or modelled (the groove between the teeth cut into the body along a helix, SMLib's exact
|
||||
screw sweep). This page gives the forms, the values each uses and where they come from, and how a
|
||||
thread's extent is decided. The code is `crates/doc/src/thread_forms.rs` (forms, sizes,
|
||||
designations) and `crates/doc/src/eval_thread.rs` (the thread on its face, the cut).
|
||||
|
||||
## Forms
|
||||
|
||||
Every groove is a polygon in an axial half-plane: straight flanks, flat crest and root. Crest and
|
||||
root radii (the ISO metric root radius, the buttress root radius R, DIN 405 knuckle and Whitworth
|
||||
rounds) are not modelled. External threads are cut from the shaft's surface (a little past it, so
|
||||
no skin is left) down to the external thread's minor diameter; internal threads from the hole
|
||||
wall out to the internal thread's root. Pitch-diameter allowances and tolerance classes are not
|
||||
modelled: a modelled thread is the maximum-material design profile below.
|
||||
|
||||
| Form | `form` | Designation | Flanks | Depth | Crest flat | External minor | Internal minor, root |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ISO metric | `metric` | `M10`, `M10x1`, `M16xPh3P1.5` | 30° / 30° | 5H/8, H = P √3/2 | P/8 at d, P/4 at D1 | d − 5H/4 | D1 = d − 5H/4, D = d |
|
||||
| Trapezoidal | `trapezoidal` | `Tr20x4`, `Tr40x14(P7)` | 15° / 15° | H1 = P/2 | P/2 (1 − tan 15°) = 0.366 P | d3 = d − 2 (H1 + ac) | D1 = d − P, D4 = d + 2 ac |
|
||||
| Acme (general purpose) | `acme` | `1/2-10 ACME` | 14.5° / 14.5° | h = P/2 | P/2 (1 − tan 14.5°) = 0.3707 P | d − P − 0.020″ (0.010″ finer than 10 tpi) | D − P, D + 0.020″ (0.010″) |
|
||||
| Metric buttress | `buttress` | `S20x4`, `S40x14(P7)` | 3° (load) / 30° | H1 = 0.75 P | (P − 0.75 P (tan 3° + tan 30°))/2 = 0.26384 P | d3 = d − 2 × 0.86777 P | D1 = d − 1.5 P, D = d |
|
||||
| Buttress inch | `buttress_asme` | `2.5-8 BUTT` | 7° (load) / 45° | h = 0.6 P | (P − 0.6 P (tan 7° + 1))/2 = 0.16316 P | d − 1.2 P (basic) | d − 1.2 P, d (basic) |
|
||||
|
||||
Trapezoidal clearance ac (ISO 2904): 0.15 mm for P = 1.5, 0.25 mm for P 2 to 5, 0.5 mm for P 6 to
|
||||
12, 1 mm for P 14 to 44.
|
||||
|
||||
The square thread is refused: no current ISO, ASME or DIN standard defines one. Machinery's
|
||||
Handbook gives only historical proportions (depth and width P/2; the "modified square" 10°); the
|
||||
trapezoidal and Acme forms replaced it.
|
||||
|
||||
**Sizes.** Without a designation the thread takes the standard size that fits the face: a shaft
|
||||
its major diameter, a hole its drill (metric: the tap drill d − P; the other forms: the internal
|
||||
minor diameter). Metric uses the ISO 261/262 coarse pitches (M1.6 to M56); trapezoidal and metric
|
||||
buttress the ISO 2902 middle pitch of each diameter (Tr8x1.5 to Tr130x14; buttress from S10x2),
|
||||
the series DIN 513-2 shares; Acme ASME B1.5's preferred sizes (1/4-16 to 5-2). Buttress inch has
|
||||
no size table here: its pitch is always given.
|
||||
|
||||
**Hand.** `hand: right | left | both`. A left-handed groove is the right-handed one's mirror image
|
||||
(the screw motion about the reversed axis, rising along it); `both` cuts a right- and a
|
||||
left-handed groove crossing on the face (a reversing screw).
|
||||
|
||||
**Buttress direction.** `steep_flank: end | start` says which way the steep (load) flank of the
|
||||
screw's teeth faces along the thread's axis; a nut cut with the same setting mates with it (its
|
||||
own steep flanks face the other way).
|
||||
|
||||
## Extent
|
||||
|
||||
`extent: full | length | up_to`, `length`, `up_to` (a point's place along the axis, or where a
|
||||
plane crosses it), `from` (a point near the end the thread starts at).
|
||||
|
||||
A face end is **free** when no material lies past it in the groove's way: over the span from one
|
||||
pitch to one and a half past the end, within the radii the groove cuts (the external minor
|
||||
diameter to the major, a nut's minor diameter to its root, and the face itself). A cap, a chamfer
|
||||
the groove runs out through, a counterbore wider than the thread are free; a shoulder, a head
|
||||
whose corners reach past the root, a blind hole's bottom are not.
|
||||
|
||||
- At a free end the groove runs out a pitch past the end, into the air.
|
||||
- At a closed end it stops with its section clear of the end: its centre as far inside as the
|
||||
section reaches that way, so it never cuts into the shoulder.
|
||||
- Inside the face it stops square. For a symmetric section a square stop inside material removes
|
||||
exactly length / P turns (what the last section leaves uncut ahead of the stop, it cuts behind
|
||||
it in mirror image).
|
||||
|
||||
A thread of some length starts at the end nearest `from`; without it, at the face's only free end;
|
||||
without one, at 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, naming `from`. A length as long as the face runs the
|
||||
whole face.
|
||||
|
||||
## Sources
|
||||
|
||||
- ISO 68-1 (basic profile), ISO 261/262 (sizes), ISO 965-1 (multi-start designation `Ph`).
|
||||
- ISO 2901 to 2904 (trapezoidal: basic and design profiles, series, basic dimensions); the
|
||||
design diameters checked against the Bornemann Gewindetechnik tables of DIN 103
|
||||
(Tr10x2: d3 7.5, D4 10.5, D1 8; Tr40x7: 32, 41, 33; Tr100x20: 78, 102, 80):
|
||||
<https://www.bornemann-gewindetechnik.de/de/produkte/trapezgewinde/daten/>.
|
||||
- DIN 513-1/-2 (metric buttress): the formulas (ac = 0.11777 P, h3 = 0.86777 P, flat w =
|
||||
0.26384 P, D1 = d − 2 H1) and the diameter-pitch table as IS 4696 (Part 3): 2004 publishes them
|
||||
in line with DIN 513 (<https://law.resource.org/pub/in/bis/S01/is.4696.3.2004.pdf>), and the
|
||||
Bornemann tables (S10x2: d3 6.529, D1 7; S40x7: 27.851, 29.5):
|
||||
<https://www.bornemann-gewindetechnik.de/de/produkte/saegengewinde/daten/>.
|
||||
- ASME B1.5 (Acme): flat 0.3707 P, h = P/2, the 0.020″/0.010″ minor and major allowances and the
|
||||
preferred sizes, as reproduced at <https://torqbolt.com/unified-acme-threads-dimensions-specifications>.
|
||||
- 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.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled trapezoidal Tr20x4 thread (ISO 2904) on a d20 shaft, six turns",
|
||||
"source": "analytic",
|
||||
"method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ISO 2904: 30 deg, H1 = P/2, crest flat P/2 (1 - tan15) = 0.366 P, external minor d3 = d - 2 (H1 + ac) = 15.5 (ac = 0.25 for P 2..5)",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 6065.46543972,
|
||||
"tolerance": {
|
||||
"rel": 0.0001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "solid.cylinder",
|
||||
"params": {
|
||||
"base": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"radius": 10.0,
|
||||
"height": 24,
|
||||
"body_name": "Part"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.thread",
|
||||
"params": {
|
||||
"face": [
|
||||
10.0,
|
||||
0,
|
||||
12.0
|
||||
],
|
||||
"modeled": true,
|
||||
"form": "trapezoidal",
|
||||
"designation": "Tr20x4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled 3/4-6 ACME thread (ASME B1.5, general purpose) on a 3/4 in shaft, six turns",
|
||||
"source": "analytic",
|
||||
"method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ASME B1.5: 29 deg, h = P/2, crest flat 0.3707 P, external minor d - P - 0.020 in (10 threads per inch or coarser)",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 5689.272256144,
|
||||
"tolerance": {
|
||||
"rel": 0.0001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "solid.cylinder",
|
||||
"params": {
|
||||
"base": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"radius": 9.524999999999999,
|
||||
"height": 25.4,
|
||||
"body_name": "Part"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.thread",
|
||||
"params": {
|
||||
"face": [
|
||||
9.524999999999999,
|
||||
0,
|
||||
12.7
|
||||
],
|
||||
"modeled": true,
|
||||
"form": "acme",
|
||||
"designation": "3/4-6 ACME"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled metric buttress S20x4 thread (DIN 513, 3/30 deg) on a d20 shaft, six turns",
|
||||
"source": "analytic",
|
||||
"method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. DIN 513-1: 3 deg load flank, 30 deg clearance flank, H1 = 0.75 P, flats (P - 0.75 P (tan3 + tan30))/2 = 0.26384 P, external minor d3 = d - 2 x 0.86777 P = 13.058 (IS 4696-3 table)",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 5454.579522681,
|
||||
"tolerance": {
|
||||
"rel": 0.0001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "solid.cylinder",
|
||||
"params": {
|
||||
"base": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"radius": 10.0,
|
||||
"height": 24,
|
||||
"body_name": "Part"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.thread",
|
||||
"params": {
|
||||
"face": [
|
||||
10.0,
|
||||
0,
|
||||
12.0
|
||||
],
|
||||
"modeled": true,
|
||||
"form": "buttress",
|
||||
"designation": "S20x4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled 1.5-8 buttress thread (ASME B1.9 basic profile, 7/45 deg) on a 1.5 in shaft, six turns",
|
||||
"source": "analytic",
|
||||
"method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ASME B1.9 basic profile: 7 deg load flank, 45 deg clearance flank, h = 0.6 P, flats (P - 0.6 P (tan7 + tan45))/2 = 0.16316 P; the root radius and allowances are not modelled",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 19631.082660727,
|
||||
"tolerance": {
|
||||
"rel": 0.0001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "solid.cylinder",
|
||||
"params": {
|
||||
"base": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"radius": 19.049999999999997,
|
||||
"height": 19.049999999999997,
|
||||
"body_name": "Part"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.thread",
|
||||
"params": {
|
||||
"face": [
|
||||
19.049999999999997,
|
||||
0,
|
||||
9.524999999999999
|
||||
],
|
||||
"modeled": true,
|
||||
"form": "buttress_asme",
|
||||
"designation": "1.5-8 BUTT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled internal Tr20x4 thread in a d16 (D1 = d - P) bore through a 40x40x24 block",
|
||||
"source": "analytic",
|
||||
"method": "groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); six whole turns through the block, both ends free, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ISO 2904 internal: the nut's crest flat 0.366 P at D1 = d - P = 16, its root at D4 = d + 2 ac = 20.5; the groove from the bore wall r 8 out to r 10.25, w(r) = P - 0.366 P - (r - 8) 2 tan15",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 32097.519700251,
|
||||
"tolerance": {
|
||||
"rel": 0.0001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"corner": [
|
||||
-20,
|
||||
-20,
|
||||
0
|
||||
],
|
||||
"length": 40,
|
||||
"width": 40,
|
||||
"height": 24,
|
||||
"body_name": "Part"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.cylinder",
|
||||
"params": {
|
||||
"base": [
|
||||
0,
|
||||
0,
|
||||
-1
|
||||
],
|
||||
"radius": 8,
|
||||
"height": 26,
|
||||
"body_name": "Bore"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.combine",
|
||||
"params": {
|
||||
"target": "Part",
|
||||
"tools": [
|
||||
"Bore"
|
||||
],
|
||||
"operation": "cut"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.thread",
|
||||
"params": {
|
||||
"face": [
|
||||
8,
|
||||
0,
|
||||
12
|
||||
],
|
||||
"modeled": true,
|
||||
"designation": "Tr20x4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "001's modelled M10 thread, left-handed: the same volume, turning the other way",
|
||||
"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; a mirror image of 001's groove, so the same volume",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 1954.24591725,
|
||||
"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",
|
||||
"hand": "left"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -107,7 +107,13 @@
|
||||
"ladder/06-threads/006-external-m10-into-chamfer",
|
||||
"ladder/06-threads/002-external-m10x1-fine",
|
||||
"ladder/06-threads/008-multi-start-thread",
|
||||
"ladder/06-threads/009-thread-runout-length"
|
||||
"ladder/06-threads/009-thread-runout-length",
|
||||
"ladder/06-threads/011-external-tr20x4",
|
||||
"ladder/06-threads/012-external-acme-3-4-6",
|
||||
"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"
|
||||
],
|
||||
"tier": "06-threads",
|
||||
"what": "modelled threads"
|
||||
|
||||
@@ -406,6 +406,51 @@ case(T6, "010-part-threaded-boss", "the running part with a modelled M16 thread
|
||||
[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")
|
||||
|
||||
# The other standard forms (crates/doc/src/thread_forms.rs, docs/threads.md): a groove of a fixed
|
||||
# straight-flanked section, its tooth `flat` wide at the crest radius and widening by tan a + tan b
|
||||
# per unit of depth, removes 2 pi int r w(r) dr per turn (Pappus, the screw motion).
|
||||
def form_turn(P, flat, crest, a, b, r0, r1, internal=False):
|
||||
t = math.tan(math.radians(a)) + math.tan(math.radians(b))
|
||||
w = lambda r: P - flat - ((r - crest) if internal else (crest - r)) * t
|
||||
return 2 * PI * quad(lambda r: r * w(r), r0, r1, 16)
|
||||
INCH = 25.4
|
||||
TOL_F = {"rel": 1e-4}
|
||||
FMETH = ("groove volume per turn 2 pi int r w(r) dr over the material it cuts (Pappus on the axial section; a screw motion), w(r) = P - flat - depth (tan a + tan b); "
|
||||
"six whole turns between free ends, so V = stock - 6 x that. Tolerance 0.01%: SMLib's exact screw sweep. ")
|
||||
def shaft_form(name, what, form, desig, d, P, flat, a, b, r3, extra="", **p):
|
||||
L = 6 * P
|
||||
v = PI * (d / 2) ** 2 * L - 6 * form_turn(P, flat, d / 2, a, b, r3, d / 2)
|
||||
case(T6, name, what, [cyl([0, 0, 0], d / 2, L, body_name="Part"), thr([d / 2, 0, L / 2], form=form, designation=desig, **p)],
|
||||
{"body_count": 1, "volume_mm3": v, "tolerance": TOL_F}, FMETH + extra)
|
||||
t15, t145 = math.tan(math.radians(15)), math.tan(math.radians(14.5))
|
||||
shaft_form("011-external-tr20x4", "a modelled trapezoidal Tr20x4 thread (ISO 2904) on a d20 shaft, six turns", "trapezoidal", "Tr20x4", 20, 4,
|
||||
2 * (1 - t15), 15, 15, 10 - 2 - 0.25,
|
||||
"ISO 2904: 30 deg, H1 = P/2, crest flat P/2 (1 - tan15) = 0.366 P, external minor d3 = d - 2 (H1 + ac) = 15.5 (ac = 0.25 for P 2..5)")
|
||||
shaft_form("012-external-acme-3-4-6", "a modelled 3/4-6 ACME thread (ASME B1.5, general purpose) on a 3/4 in shaft, six turns", "acme", "3/4-6 ACME", 0.75 * INCH, INCH / 6,
|
||||
INCH / 12 * (1 - t145), 14.5, 14.5, 0.375 * INCH - INCH / 12 - 0.010 * INCH,
|
||||
"ASME B1.5: 29 deg, h = P/2, crest flat 0.3707 P, external minor d - P - 0.020 in (10 threads per inch or coarser)")
|
||||
t3 = math.tan(math.radians(3)) + math.tan(math.radians(30))
|
||||
shaft_form("013-external-s20x4", "a modelled metric buttress S20x4 thread (DIN 513, 3/30 deg) on a d20 shaft, six turns", "buttress", "S20x4", 20, 4,
|
||||
(4 - 3 * t3) / 2, 3, 30, 10 - 0.86777 * 4,
|
||||
"DIN 513-1: 3 deg load flank, 30 deg clearance flank, H1 = 0.75 P, flats (P - 0.75 P (tan3 + tan30))/2 = 0.26384 P, external minor d3 = d - 2 x 0.86777 P = 13.058 (IS 4696-3 table)")
|
||||
t7 = math.tan(math.radians(7)) + 1
|
||||
P8 = INCH / 8
|
||||
shaft_form("014-external-buttress-asme-1.5-8", "a modelled 1.5-8 buttress thread (ASME B1.9 basic profile, 7/45 deg) on a 1.5 in shaft, six turns", "buttress_asme", "1.5-8 BUTT",
|
||||
1.5 * INCH, P8, (P8 - 0.6 * P8 * t7) / 2, 7, 45, 0.75 * INCH - 0.6 * P8,
|
||||
"ASME B1.9 basic profile: 7 deg load flank, 45 deg clearance flank, h = 0.6 P, flats (P - 0.6 P (tan7 + tan45))/2 = 0.16316 P; the root radius and allowances are not modelled")
|
||||
dT, PT = 20, 4
|
||||
vnut = 6 * form_turn(PT, 2 * (1 - t15), (dT - PT) / 2, 15, 15, (dT - PT) / 2, dT / 2 + 0.25, internal=True)
|
||||
case(T6, "015-internal-tr20x4-nut", "a modelled internal Tr20x4 thread in a d16 (D1 = d - P) bore through a 40x40x24 block",
|
||||
[box([-20, -20, 0], 40, 40, 24, body_name="Part"), cyl([0, 0, -1], 8, 26, body_name="Bore"), C("solid.combine", target="Part", tools=["Bore"], operation="cut"),
|
||||
thr([8, 0, 12], designation="Tr20x4")],
|
||||
{"body_count": 1, "volume_mm3": 40 * 40 * 24 - PI * 64 * 24 - vnut, "tolerance": TOL_F},
|
||||
FMETH.replace("between free ends", "through the block, both ends free") + "ISO 2904 internal: the nut's crest flat 0.366 P at D1 = d - P = 16, its root at D4 = d + 2 ac = 20.5; "
|
||||
"the groove from the bore wall r 8 out to r 10.25, w(r) = P - 0.366 P - (r - 8) 2 tan15")
|
||||
case(T6, "016-external-m10-left-hand", "001's modelled M10 thread, left-handed: the same volume, turning the other way", cylT + [thr([5, 0, 15], designation="M10", hand="left")],
|
||||
{"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") +
|
||||
"; a mirror image of 001's groove, so the same volume")
|
||||
|
||||
# ---------------------------------------------------------------- 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)],
|
||||
|
||||
Reference in New Issue
Block a user