mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 12:01:10 +00:00
Threads start at the end meant and stop clear of a shoulder
A thread of some length ran from "the end of the face nearest the pick", a coin toss for a pick in the middle (rig 602's 4 mm thread meant for the free bottom end ran at the head end, cutting 1.17 mm into the hex), and every thread end at a face end got a one-pitch overrun, which at a shoulder gouged the material past it (ladder 06/004 cut 0.9% of its volume out of the collar). - kernel: material_in_ring, the exact intersection of a body with a ring about a cylinder face's axis, by an SMLib boolean. - doc: a face end is free when no material lies past it, over the span the groove's one-pitch overrun reaches beyond its first pitch, within the radii the groove cuts (a cap, a chamfer the groove runs out through, a counterbore). At a free end the groove runs out as before; at a closed one (a shoulder, a head, a blind hole's bottom) it stops with its widest part clear of it; inside the face it stops square. - doc: a thread of some length starts at the end nearest `from`, else at the face's only free end, else at the end the pick 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. `up_to` a plane or a point's place along the axis. New fields `from`, `up_to` default to none, so saved threads keep their meaning where it was not a coin toss. - engine: solid.thread takes extent (full|length|up_to), from and up_to. - ladder 06/003 (now with `from`), 004 and 007 are graded by volume and centre of mass in closed form (a square stop inside material removes exactly length/P turns, the section being symmetric); rig 602's method says why the thread is at the bottom, tolerance 0.02%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,7 @@ impl crate::Feature {
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => checked_plane(plane, 0)?,
|
||||
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => checked_plane(plane, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
@@ -268,6 +269,7 @@ impl crate::Feature {
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
|
||||
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, bodies, 0)?;
|
||||
|
||||
@@ -858,7 +858,7 @@ pub enum FeatureKind {
|
||||
face: FacePick,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
designation: Option<String>,
|
||||
/// Thread length from the face's end nearest `face`; `None` = full length.
|
||||
/// Thread length from the end it starts at; `None` = full length (or `up_to`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Length")]
|
||||
length: Option<Expr>,
|
||||
@@ -866,6 +866,13 @@ pub enum FeatureKind {
|
||||
modeled: bool,
|
||||
#[serde(default, skip_serializing_if = "ThreadHandedness::is_right")]
|
||||
handedness: ThreadHandedness,
|
||||
/// A point near the end of the face the thread starts at; `None`: the face's only free
|
||||
/// end (no material past it), or the end the pick at `face` lies near.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
from: Option<Position>,
|
||||
/// 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>,
|
||||
},
|
||||
/// Ruled loft through profiles of several sketches, in order.
|
||||
Loft {
|
||||
@@ -1845,6 +1852,16 @@ impl ExtendMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a thread runs up to instead of a length.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThreadLimit {
|
||||
/// Where this point lies along the thread's axis (a point on a face, such as a shoulder).
|
||||
Face(FacePick),
|
||||
/// Where this plane crosses the thread's axis.
|
||||
Plane(PlaneRef),
|
||||
}
|
||||
|
||||
/// What Extend extends up to instead of a distance.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
@@ -6410,7 +6410,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Thread { .. } => thread::eval(vals, f, st),
|
||||
FeatureKind::Thread { .. } => thread::eval(doc, vals, f, st),
|
||||
FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => {
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
// No face opened (a closed hollow body) picks no body: the one there is, or the named one.
|
||||
|
||||
+202
-133
@@ -88,44 +88,101 @@ pub(crate) fn format_thread(d: f64, p: f64) -> String {
|
||||
/// 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, None, None, false).map(|t| t.designation)
|
||||
thread_on(st, 0, p, &ThreadAsk::default()).map(|t| t.info.designation)
|
||||
}
|
||||
|
||||
/// A cosmetic thread on the cylindrical face at `p`. A `turned` (modelled) thread also fits a
|
||||
/// shaft thicker than it, which it turns down to its major diameter first.
|
||||
pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>, turned: bool) -> Result<ThreadInfo> {
|
||||
thread_on_impl(st, feature, p, designation, length, false, turned)
|
||||
/// How far a thread runs along its face.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) enum Extent {
|
||||
/// The whole face.
|
||||
#[default]
|
||||
Full,
|
||||
/// This long, from the end it starts at.
|
||||
Length(f64),
|
||||
/// From the end it starts at to where this plane crosses the axis.
|
||||
UpTo(Plane),
|
||||
/// From the end it starts at to this point's place along the axis.
|
||||
UpToPoint(Vec3),
|
||||
}
|
||||
|
||||
// Tapped holes admit verified Unified and parallel pipe sizes; the separate Thread feature remains metric,
|
||||
// including when an untrusted design requests modelled geometry.
|
||||
pub(super) fn thread_on_impl(
|
||||
st: &ModelState,
|
||||
feature: u64,
|
||||
p: Vec3,
|
||||
designation: Option<&str>,
|
||||
length: Option<f64>,
|
||||
tapped: bool,
|
||||
turned: bool,
|
||||
) -> Result<ThreadInfo> {
|
||||
let (body, cyl) = st
|
||||
/// 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> {
|
||||
pub designation: Option<&'a str>,
|
||||
pub extent: Extent,
|
||||
/// A point near the end of the face the thread starts at.
|
||||
pub from: Option<Vec3>,
|
||||
/// A modelled thread needs to know how each of its ends meets the face's ends.
|
||||
pub modeled: bool,
|
||||
}
|
||||
|
||||
/// How one end of a thread's length meets its face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum End {
|
||||
/// At an end of the face past which there is no material in the groove's way: the groove
|
||||
/// runs out into the air (or through a chamfer).
|
||||
Free,
|
||||
/// At (or within half a pitch of) an end of the face where material goes on past it, a
|
||||
/// shoulder, a head, a blind hole's bottom: the groove stops short, clear of it.
|
||||
Closed,
|
||||
/// Inside the face: the groove stops square there.
|
||||
Inside,
|
||||
}
|
||||
|
||||
/// A thread resolved on its face.
|
||||
pub(super) struct ThreadOn {
|
||||
pub info: ThreadInfo,
|
||||
cyl: kernel::CylinderFace,
|
||||
/// How the thread's start and end meet the face (modelled threads only).
|
||||
ends: [End; 2],
|
||||
}
|
||||
|
||||
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
|
||||
/// once: whether any material lies past the end, over the axial span the groove's one-pitch
|
||||
/// overrun reaches beyond its first pitch, within the radii the groove cuts.
|
||||
struct FaceEnds<'a> {
|
||||
body: &'a Body,
|
||||
cyl: &'a kernel::CylinderFace,
|
||||
band: (f64, f64),
|
||||
pitch: f64,
|
||||
free: [Option<bool>; 2],
|
||||
}
|
||||
|
||||
impl FaceEnds<'_> {
|
||||
fn free(&mut self, side: usize) -> Result<bool> {
|
||||
if let Some(Some(f)) = self.free.get(side) {
|
||||
return Ok(*f);
|
||||
}
|
||||
let p = self.pitch;
|
||||
let along = if side == 0 { (self.cyl.start - 1.5 * p, self.cyl.start - p) } else { (self.cyl.end + p, self.cyl.end + 1.5 * p) };
|
||||
// Material that only touches the band's rims (a counterbore as wide as the thread) is
|
||||
// not in the groove's way.
|
||||
let e = p * 1e-3;
|
||||
let free = !kernel::material_in_ring(self.body, self.cyl, self.band.0 + e, self.band.1 - e, along)?;
|
||||
if let Some(slot) = self.free.get_mut(side) {
|
||||
*slot = Some(free);
|
||||
}
|
||||
Ok(free)
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread on the cylindrical face at `p`: its size, and the part of the face it runs over.
|
||||
pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk) -> Result<ThreadOn> {
|
||||
let (bi, cyl) = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter_map(|b| kernel::cylinder_face_at(&b.body, p).map(|c| (b.name.clone(), c)))
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| kernel::cylinder_face_at(&b.body, p).map(|c| (i, c)))
|
||||
.min_by(|a, b| {
|
||||
let dist = |c: &kernel::CylinderFace| ((p - c.axis_point - c.axis * (p - c.axis_point).dot(c.axis)).len() - c.radius).abs();
|
||||
dist(&a.1).total_cmp(&dist(&b.1))
|
||||
})
|
||||
.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 tapped_size = if tapped {
|
||||
let name = designation.ok_or_else(|| DocError::Invalid("a tapped hole needs a designation".into()))?;
|
||||
Some(
|
||||
crate::parse_tapped_thread(name)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown tapped thread `{name}` (ISO metric or a catalogued UNC/UNF, G or Rp size)")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
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 (major, pitch) = match &tapped_size {
|
||||
Some(size) => (size.major_diameter, size.pitch),
|
||||
@@ -146,23 +203,64 @@ pub(super) fn thread_on_impl(
|
||||
if !fits {
|
||||
return Err(DocError::Invalid(format!("an {} thread does not fit a {dia:.2} mm {}", name, if cyl.internal { "hole" } else { "shaft" })));
|
||||
}
|
||||
// From the end of the face nearest the point.
|
||||
let s = (p - cyl.axis_point).dot(cyl.axis);
|
||||
let (mut a, mut b) = (cyl.start, cyl.end);
|
||||
if let Some(l) = length {
|
||||
if !(l > 0.0) {
|
||||
return Err(DocError::Invalid("thread length must be positive".into()));
|
||||
// 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));
|
||||
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);
|
||||
let span = |side: usize, to: f64| -> Result<(f64, f64)> {
|
||||
let (a, b) = if side == 0 { (lo, to.min(hi)) } else { (to.max(lo), hi) };
|
||||
if !(b - a > pitch * 1e-3) {
|
||||
return Err(DocError::Invalid("the thread's limit is not along the face from the end it starts at".into()));
|
||||
}
|
||||
if (s - a).abs() <= (b - s).abs() {
|
||||
b = (a + l).min(b);
|
||||
Ok((a, b))
|
||||
};
|
||||
let (a, b) = match ask.extent {
|
||||
Extent::Full => (lo, hi),
|
||||
Extent::Length(l) => {
|
||||
if !(l > 0.0) {
|
||||
return Err(DocError::Invalid("thread length must be positive".into()));
|
||||
}
|
||||
if l >= hi - lo - pitch * 1e-3 {
|
||||
// As long as the face (or longer): it runs the whole face from either end.
|
||||
(lo, hi)
|
||||
} else {
|
||||
let side = start_side(&mut ends, ask.from.map(along), along(p))?;
|
||||
span(side, if side == 0 { lo + l } else { hi - l })?
|
||||
}
|
||||
}
|
||||
Extent::UpTo(pl) => {
|
||||
let n = pl.normal();
|
||||
let k = n.dot(*cyl.axis);
|
||||
if k.abs() < 1e-9 {
|
||||
return Err(DocError::Invalid("the plane the thread runs up to is parallel to its axis".into()));
|
||||
}
|
||||
let to = n.dot(pl.origin - cyl.axis_point) / k;
|
||||
span(start_side(&mut ends, ask.from.map(along), along(p))?, to)?
|
||||
}
|
||||
Extent::UpToPoint(q) => span(start_side(&mut ends, ask.from.map(along), along(p))?, along(q))?,
|
||||
};
|
||||
// How each end of the thread meets the face: at a face end, or within half a pitch of one
|
||||
// (where the groove would reach past it), that end's free-end test decides.
|
||||
let near = |at: f64, side: usize| if side == 0 { at - lo } else { hi - at } < pitch / 2.0;
|
||||
let mut kind = |at: f64, side: usize| -> Result<End> {
|
||||
Ok(if !ask.modeled || !near(at, side) {
|
||||
End::Inside
|
||||
} else if !ends.free(side)? {
|
||||
End::Closed
|
||||
} else if (if side == 0 { at - lo } else { hi - at }) < pitch * 1e-3 {
|
||||
End::Free
|
||||
} else {
|
||||
a = (b - l).max(a);
|
||||
}
|
||||
}
|
||||
Ok(ThreadInfo {
|
||||
End::Inside
|
||||
})
|
||||
};
|
||||
let ends = [kind(a, 0)?, kind(b, 1)?];
|
||||
let info = ThreadInfo {
|
||||
feature,
|
||||
body,
|
||||
designation: name,
|
||||
body: mb.name.clone(),
|
||||
designation: format_thread(major, pitch),
|
||||
major_diameter: major,
|
||||
radius: cyl.radius,
|
||||
pitch,
|
||||
@@ -171,7 +269,37 @@ pub(super) fn thread_on_impl(
|
||||
axis: cyl.axis,
|
||||
start: a,
|
||||
end: b,
|
||||
})
|
||||
};
|
||||
Ok(ThreadOn { info, cyl, ends })
|
||||
}
|
||||
|
||||
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
|
||||
/// nearest `from` (an axial position) when given, else the face's only free end, else the end the
|
||||
/// pick `at` lies near (within a third of the face). A pick in the middle of a face whose ends are
|
||||
/// alike is refused rather than guessed.
|
||||
fn start_side(ends: &mut FaceEnds, from: Option<f64>, at: f64) -> Result<usize> {
|
||||
let (lo, hi) = (ends.cyl.start, ends.cyl.end);
|
||||
if let Some(s) = from {
|
||||
return match (s - lo).abs().total_cmp(&(hi - s).abs()) {
|
||||
std::cmp::Ordering::Less => Ok(0),
|
||||
std::cmp::Ordering::Greater => Ok(1),
|
||||
std::cmp::Ordering::Equal => Err(DocError::Invalid("`from` is as near one end of the face as the other".into())),
|
||||
};
|
||||
}
|
||||
match (ends.free(0)?, ends.free(1)?) {
|
||||
(true, false) => return Ok(0),
|
||||
(false, true) => return Ok(1),
|
||||
_ => {}
|
||||
}
|
||||
let third = (hi - lo) / 3.0;
|
||||
if at - lo < third {
|
||||
Ok(0)
|
||||
} else if hi - at < third {
|
||||
Ok(1)
|
||||
} else {
|
||||
let alike = if ends.free(0)? { "both ends of the face are free" } else { "material goes on past both ends of the face" };
|
||||
Err(DocError::Invalid(format!("{alike}: say which end the thread starts from (`from`, a point near that end)")))
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread's groove as a screw sweep: its trapezoid `section` in `plane` (an axial plane
|
||||
@@ -187,9 +315,9 @@ struct GrooveShape {
|
||||
|
||||
/// 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; `open` says which ends
|
||||
/// of the threaded length are free ends of the face.
|
||||
fn thread_groove_shape(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<GrooveShape> {
|
||||
/// `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();
|
||||
@@ -250,9 +378,27 @@ fn thread_groove_shape(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Resul
|
||||
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()))?;
|
||||
// Past a free end of the face the groove runs out into the air; elsewhere it stops square
|
||||
// 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 (s0, s1) = (if open.0 { t.start - p } else { t.start }, if open.1 { t.end + p } else { t.end });
|
||||
let w = w0.max(w1);
|
||||
let s0 = match ends[0] {
|
||||
End::Free => t.start - p,
|
||||
End::Closed => t.start.max(face.0 + w),
|
||||
End::Inside => t.start,
|
||||
};
|
||||
let s1 = match ends[1] {
|
||||
End::Free => t.end + p,
|
||||
End::Closed => t.end.min(face.1 - w),
|
||||
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)
|
||||
)));
|
||||
}
|
||||
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)];
|
||||
@@ -264,103 +410,26 @@ fn thread_groove_shape(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Resul
|
||||
|
||||
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
|
||||
/// one cut into its body.
|
||||
/// What a modelled external thread turns off a shaft thicker than its major diameter before it
|
||||
/// cuts, as a lathe would: the ring between the major diameter and the shaft over the threaded
|
||||
/// length, run on past an end of the face that nothing of the part (`mb`) lies beyond. `None`
|
||||
/// for a shaft no thicker than the thread. Its inner wall is the turned face, named `turned`;
|
||||
/// an end that stops inside the face leaves a shoulder, `shoulder:start` or `shoulder:end`.
|
||||
fn thread_turn(t: &ThreadInfo, cyl: &kernel::CylinderFace, mb: &ModelBody) -> Result<Option<Traced>> {
|
||||
let (p, r) = (t.pitch, t.major_diameter / 2.0);
|
||||
if t.internal || !(cyl.radius > r * (1.0 + 1e-6)) {
|
||||
return Ok(None);
|
||||
}
|
||||
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 v = k.cross(u);
|
||||
let mesh = mb.mesh();
|
||||
let solid_round = |s: f64, radius: f64| {
|
||||
(0..12).any(|i| {
|
||||
let a = std::f64::consts::TAU * f64::from(i) / 12.0;
|
||||
mesh.contains(t.axis_point + *k * s + (u * a.cos() + v * a.sin()) * radius)
|
||||
})
|
||||
};
|
||||
// The ring reaches a little past the shaft; the part must not go on there (a shaft standing
|
||||
// in a corner, a rounded edge), or the turning would cut into it.
|
||||
let out = cyl.radius + 0.1 * p;
|
||||
if [0.25, 0.5, 0.75].iter().any(|f| solid_round(t.start + (t.end - t.start) * f, cyl.radius + 0.05 * p)) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"this {} mm shaft is too thick for a modelled {} thread, and the part around it keeps it from being turned down; make the shaft {} mm",
|
||||
show_mm(2.0 * cyl.radius),
|
||||
t.designation,
|
||||
show_mm(t.major_diameter)
|
||||
)));
|
||||
}
|
||||
// Past a free end of the face out into the air; elsewhere square at the threaded length's end.
|
||||
let tol = p * 1e-3;
|
||||
let mid = (r + cyl.radius) / 2.0;
|
||||
let free = |at: f64, s: f64, past: f64| (at - s).abs() < tol && !solid_round(s + past * 0.5 * p, mid);
|
||||
let (open0, open1) = (free(cyl.start, t.start, -1.0), free(cyl.end, t.end, 1.0));
|
||||
let s0 = if open0 { t.start - p } else { t.start };
|
||||
let s1 = if open1 { t.end + p } else { t.end };
|
||||
let plane = Plane { origin: t.axis_point, x: u, y: *k };
|
||||
let ring = [Vec2::new(r, s0), Vec2::new(out, s0), Vec2::new(out, s1), Vec2::new(r, s1)];
|
||||
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&ring), holes: vec![] };
|
||||
let ring = kernel::revolve(&plane, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| DocError::Invalid("thread: the shaft turned down to the thread".into()))?;
|
||||
// The ring's sides by profile segment: its start end, outside, end end, inner wall.
|
||||
let role = |segment: usize| -> Vec<String> {
|
||||
match segment {
|
||||
0 if !open0 => vec!["shoulder:start".into()],
|
||||
2 if !open1 => vec!["shoulder:end".into()],
|
||||
3 => vec!["turned".into()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
let roles = ring.history().filter(|h| h.operands == 0).map(|h| {
|
||||
h.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[kernel::history::FaceOrigin::Profile { segment }] => role(*segment),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(Some(Traced::made(ring, roles)))
|
||||
}
|
||||
|
||||
pub(super) fn eval(vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
let FeatureKind::Thread { face, designation, length, modeled } = &f.kind else {
|
||||
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 {
|
||||
return Err(DocError::Invalid("not a thread".into()));
|
||||
};
|
||||
let l = match length {
|
||||
Some(e) => Some(val(vals, e, Kind::Length)?),
|
||||
None => None,
|
||||
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 t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled)?;
|
||||
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)?;
|
||||
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())) };
|
||||
let cyl = kernel::cylinder_face_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
|
||||
let tol = t.pitch * 1e-3;
|
||||
// A shaft thicker than the thread is turned down to its major diameter first.
|
||||
let input = mb.traced();
|
||||
let (input, radius) = match thread_turn(&t, &cyl, &mb)? {
|
||||
Some(ring) => {
|
||||
let turned = kernel::boolean(&input.body, &ring.body, BoolOp::Cut)?
|
||||
.ok_or_else(|| DocError::Invalid("turning the shaft down removed everything".into()))?;
|
||||
(Traced::step(turned, &[&input, &ring]), t.major_diameter / 2.0)
|
||||
}
|
||||
None => (input, cyl.radius),
|
||||
};
|
||||
let open = ((t.start - cyl.start).abs() < tol, (t.end - cyl.end).abs() < tol);
|
||||
// The exact groove: SMLib's screw sweep, cut by a B-rep boolean.
|
||||
let g = thread_groove_shape(&t, radius, open)?;
|
||||
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());
|
||||
|
||||
@@ -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); length?: expr (default: the whole face); handedness?: right|left (default right); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; 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; 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"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -2906,6 +2906,25 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = &length {
|
||||
check_expr(s, l, Kind::Length, cmd, "length")?;
|
||||
}
|
||||
let up_to = match p.get("up_to") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(match vec3(v) {
|
||||
Some(q) => solidcraft_doc::ThreadLimit::Face(FacePick(q)),
|
||||
None => solidcraft_doc::ThreadLimit::Plane(plane_param(s, Some(v), cmd)?),
|
||||
}),
|
||||
};
|
||||
match (str_(p, "extent"), &length, &up_to) {
|
||||
(None, Some(_), Some(_)) => return Err(bad(cmd, "give `length` or `up_to`, not both")),
|
||||
(None, ..) | (Some("full"), None, None) | (Some("length"), Some(_), None) | (Some("up_to"), None, Some(_)) => {}
|
||||
(Some("full"), ..) => return Err(bad(cmd, "`extent: full` runs the whole face: it takes no `length` or `up_to`")),
|
||||
(Some("length"), ..) => return Err(bad(cmd, "`extent: length` needs `length` (and no `up_to`)")),
|
||||
(Some("up_to"), ..) => return Err(bad(cmd, "`extent: up_to` needs `up_to` (and no `length`)")),
|
||||
(Some(e), ..) => return Err(bad(cmd, format!("unknown extent `{e}` (full, length or up_to)"))),
|
||||
}
|
||||
let from = match p.get("from") {
|
||||
None | Some(Value::Null) => None,
|
||||
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);
|
||||
if designation.is_none()
|
||||
&& let Some(msg) = beyond_coarse_range(&s.model.state(), face)
|
||||
@@ -2913,7 +2932,7 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 })?;
|
||||
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled, handedness, from, up_to })?;
|
||||
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}))
|
||||
|
||||
@@ -59,7 +59,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"solid.hole" => &[("position", Pick), ("direction", Dir), ("body", Body)],
|
||||
"solid.scale" => &[("origin", Make), ("bodies", Body)],
|
||||
"solid.offset_face" | "solid.shell" => &[("faces", Picks), ("body", Body)],
|
||||
"solid.thread" => &[("face", Pick)],
|
||||
"solid.thread" => &[("face", Pick), ("from", Pick), ("up_to", Plane)],
|
||||
"solid.emboss" => &[("face", Make)],
|
||||
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
|
||||
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
|
||||
@@ -274,6 +274,10 @@ impl Mapper<'_> {
|
||||
}
|
||||
|
||||
fn plane(&mut self, v: &Value) -> Option<Value> {
|
||||
// A point where a plane may be given (a thread's `up_to`): a pick.
|
||||
if v.is_array() {
|
||||
return self.pick(v, None);
|
||||
}
|
||||
let o = v.as_object()?;
|
||||
let mut m = o.clone();
|
||||
if let Some(x) = o.get("face").and_then(|x| self.pick(x, None)) {
|
||||
|
||||
@@ -1805,7 +1805,7 @@ fn holes_at_sketch_points_threads_and_components() {
|
||||
assert_eq!(threads[0]["internal"], true);
|
||||
// A cosmetic thread on a shaft; designation from its size.
|
||||
run(&mut s, "solid.cylinder", json!({"base": [100, 0, 0], "radius": 4, "height": 20, "name": "Shaft", "body_name": "Shaft"}));
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [104, 0, 10], "length": 12}));
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [104, 0, 10], "length": 12, "from": [100, 0, 0]}));
|
||||
assert_eq!(th["thread"]["designation"], "M8", "{th}");
|
||||
assert!((th["thread"]["end"].as_f64().unwrap() - th["thread"]["start"].as_f64().unwrap() - 12.0).abs() < 1e-6, "{th}");
|
||||
assert!(s.execute("solid.thread", &json!({"face": [104, 0, 10], "designation": "M20"})).is_err());
|
||||
|
||||
@@ -625,9 +625,8 @@ fn hex_nut(hole: f64) -> Session {
|
||||
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
|
||||
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
|
||||
/// preview then compared each of them with the whole old body. With SMLib the thread is exact (a
|
||||
/// few dozen faces), and the hex's corners clip a corner of the groove's flank into a three-edge
|
||||
/// face whose base is an iso-line: meshing the body to pick the edges refused it, because sag
|
||||
/// refinement split that face's boundary at a sliver's centroid.
|
||||
/// few dozen faces). The 4 mm thread starts at the shaft's free bottom end: the hex's corners
|
||||
/// (2.31 mm out, past the M5 root at 2.07 mm) stand in the groove's way at the top end.
|
||||
#[test]
|
||||
fn fillet_beside_a_modelled_thread_finishes() {
|
||||
let mut s = Session::default();
|
||||
@@ -657,3 +656,129 @@ fn fillet_beside_a_modelled_thread_finishes() {
|
||||
let volume_after = volume(&mut s, "Screw");
|
||||
assert!(volume_after < volume_before && volume_after > volume_before * 0.99, "{volume_before} -> {volume_after}");
|
||||
}
|
||||
|
||||
/// The SMLib volume of a body (exact surfaces), for closed-form checks.
|
||||
fn exact_volume(s: &Session, b: &str) -> f64 {
|
||||
let st = s.model.state();
|
||||
st.body(b).map(|mb| solidcraft_kernel::measure(&mb.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// One turn of the ISO 68-1 external groove inside a shaft of radius `rs` (its section clipped at
|
||||
/// the surface): 2π∫ r g(r) dr from the root, g = P/4 + 2 (r - root) tan 30°.
|
||||
fn ext_turn(d: f64, p: f64, rs: f64) -> f64 {
|
||||
let root = d / 2.0 - 5.0 * p * 3f64.sqrt() / 16.0;
|
||||
let (a, b) = (p / 4.0 - 2.0 * root * (PI / 6.0).tan(), 2.0 * (PI / 6.0).tan());
|
||||
let f = |r: f64| a * r * r / 2.0 + b * r * r * r / 3.0;
|
||||
2.0 * PI * (f(rs) - f(root))
|
||||
}
|
||||
|
||||
/// The groove's half width at its widest: for an external thread at the tool's outer edge,
|
||||
/// 0.05 P past the crest (the section reaches past the surface so the cut leaves no skin).
|
||||
fn ext_half_width(d: f64, p: f64) -> f64 {
|
||||
let root = d / 2.0 - 5.0 * p * 3f64.sqrt() / 16.0;
|
||||
(p / 4.0 + 2.0 * (d / 2.0 + 0.05 * p - root) * (PI / 6.0).tan()) / 2.0
|
||||
}
|
||||
|
||||
/// A d10 shaft (z 10..35) on a d16 collar (z 0..10), as ladder 06/004.
|
||||
fn shaft_on_collar() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 8, "height": 10, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 10], "radius": 5, "height": 25, "operation": "join", "targets": ["Part"]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Rig 602, ladder 06/004: a thread on a shaft ran a pitch past both ends of its face, into the
|
||||
/// collar below as into the air above. At a shoulder it stops with its widest part clear of it:
|
||||
/// the collar keeps its whole π 8² 10, and the groove takes (25 - w)/P turns, w its half width
|
||||
/// (a square stop inside material takes exactly its length over P, the section being symmetric).
|
||||
#[test]
|
||||
fn a_thread_stops_clear_of_a_shoulder() {
|
||||
let mut s = shaft_on_collar();
|
||||
run(&mut s, "solid.thread", json!({"face": [5, 0, 25], "designation": "M10", "modeled": true}));
|
||||
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
|
||||
let want = PI * 64.0 * 10.0 + PI * 25.0 * 25.0 - (25.0 - w) / p * ext_turn(10.0, p, 5.0);
|
||||
let got = exact_volume(&s, "Part");
|
||||
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
||||
}
|
||||
|
||||
/// Rig 602: "from the end nearest the pick" took the head end for a pick a little above the
|
||||
/// middle. A thread of limited length starts at the face's only free end, wherever it is picked.
|
||||
#[test]
|
||||
fn a_thread_of_some_length_starts_at_the_only_free_end() {
|
||||
let mut s = shaft_on_collar();
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [5, 0, 15], "designation": "M10", "length": 6}));
|
||||
let (a, b) = (th["thread"]["start"].as_f64().unwrap(), th["thread"]["end"].as_f64().unwrap());
|
||||
// The axis points up (+z) from the shaft's own axis point; the free end is the top, z = 35.
|
||||
let k = th["thread"]["axis"][2].as_f64().unwrap();
|
||||
let z = |t: f64| th["thread"]["axis_point"][2].as_f64().unwrap() + k * t;
|
||||
let (z0, z1) = (z(a).min(z(b)), z(a).max(z(b)));
|
||||
assert!((z0 - 29.0).abs() < 1e-6 && (z1 - 35.0).abs() < 1e-6, "{th}");
|
||||
}
|
||||
|
||||
/// Ladder 06/003: a pick in the middle of a face whose two ends are both free was a coin toss
|
||||
/// (it fell to whichever end rounding favoured). It is refused, naming `from`; with `from` it runs
|
||||
/// from that end, and a square stop inside the face takes exactly length / P turns.
|
||||
#[test]
|
||||
fn a_mid_face_pick_between_two_free_ends_needs_from() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 30, "body_name": "Part"}));
|
||||
let e = s.execute("solid.thread", &json!({"face": [5, 0, 15], "designation": "M10", "length": 15, "modeled": true}));
|
||||
let e = e.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("both ends of the face are free") && e.contains("`from`"), "{e}");
|
||||
run(&mut s, "solid.thread", json!({"face": [5, 0, 15], "designation": "M10", "length": 15, "from": [0, 0, 30], "modeled": true}));
|
||||
let want = PI * 25.0 * 30.0 - 10.0 * ext_turn(10.0, 1.5, 5.0);
|
||||
let got = exact_volume(&s, "Part");
|
||||
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
||||
// The groove is all in the top half: just under the surface, the bottom 15 mm (less the
|
||||
// stop's half width) is whole and the top has the groove's turns.
|
||||
let st = s.model.state();
|
||||
let body = &st.body("Part").unwrap().body;
|
||||
let ring = |z: f64| (0..36).map(|i| (i as f64 * 10.0).to_radians()).map(move |a| Vec3::new(4.9 * a.cos(), 4.9 * a.sin(), z));
|
||||
let rings = |z0: f64, z1: f64| (0..=40).map(move |i| z0 + (z1 - z0) * i as f64 / 40.0).flat_map(ring);
|
||||
assert!(rings(0.5, 14.0).all(|q| body.contains(q)), "the bottom half is cut");
|
||||
assert!(rings(16.0, 29.5).filter(|q| !body.contains(*q)).count() > 100, "the top half is not threaded");
|
||||
}
|
||||
|
||||
/// Up to a plane, or to a point's place along the axis, from the end given.
|
||||
#[test]
|
||||
fn a_thread_runs_up_to_a_plane_or_a_point() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 20, "body_name": "Shaft"}));
|
||||
let z = |th: &Value| {
|
||||
let t = &th["thread"];
|
||||
let at = |u: &str| t["axis_point"][2].as_f64().unwrap() + t["axis"][2].as_f64().unwrap() * t[u].as_f64().unwrap();
|
||||
let (a, b) = (at("start"), at("end"));
|
||||
(a.min(b), a.max(b))
|
||||
};
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [4, 0, 10], "up_to": {"origin": [0, 0, 12], "normal": [0, 0, 1]}, "from": [0, 0, 0]}));
|
||||
let (z0, z1) = z(&th);
|
||||
assert!(z0.abs() < 1e-6 && (z1 - 12.0).abs() < 1e-6, "{th}");
|
||||
let th = run(&mut s, "solid.thread", json!({"face": [4, 0, 10], "extent": "up_to", "up_to": [4, 0, 5], "from": [0, 0, 20]}));
|
||||
let (z0, z1) = z(&th);
|
||||
assert!((z0 - 5.0).abs() < 1e-6 && (z1 - 20.0).abs() < 1e-6, "{th}");
|
||||
// An extent that contradicts the values given is refused.
|
||||
let e = s.execute("solid.thread", &json!({"face": [4, 0, 10], "extent": "full", "length": 5})).err().map(|e| e.to_string());
|
||||
assert!(e.unwrap_or_default().contains("extent: full"));
|
||||
}
|
||||
|
||||
/// A blind tapped hole: the thread runs out of the entry and stops clear of the bottom, the
|
||||
/// groove taking (depth - w)/P turns of 2π∫ r g(r) dr over the wall, w its widest half width (at
|
||||
/// the tool's inner edge, 0.1 P inside the minor diameter).
|
||||
#[test]
|
||||
fn a_blind_tapped_hole_stops_clear_of_its_bottom() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 20, "body_name": "Block"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [0, 0, 20], "diameter": 8.5, "depth": 12}));
|
||||
let before = exact_volume(&s, "Block");
|
||||
run(&mut s, "solid.thread", json!({"face": [4.25, 0, 14], "designation": "M10", "modeled": true}));
|
||||
let (d, p, rh) = (10.0, 1.5, 4.25);
|
||||
let t = (PI / 6.0).tan();
|
||||
let minor = d / 2.0 - 5.0 * p * 3f64.sqrt() / 16.0;
|
||||
let w = (p / 8.0 + 2.0 * (d / 2.0 - (minor - 0.1 * p)) * t) / 2.0;
|
||||
// g(r) = P/8 + 2 (d/2 - r) tan 30°, over the wall r 4.25..5.
|
||||
let f = |r: f64| (p / 8.0 + d * t) * r * r / 2.0 - 2.0 * t * r * r * r / 3.0;
|
||||
let turn = 2.0 * PI * (f(d / 2.0) - f(rh));
|
||||
let want = before - (12.0 - w) / p * turn;
|
||||
let got = exact_volume(&s, "Block");
|
||||
assert!((got - want).abs() < before * 1e-4, "{got} vs {want}");
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ pub use surfaces::{
|
||||
stitch, sweep_sheet, sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, unstitch,
|
||||
};
|
||||
pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_path_with};
|
||||
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at};
|
||||
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at, material_in_ring};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
pub use validity::{SmlibCheck, Validity, smlib_built, smlib_dump_dir, validity};
|
||||
|
||||
@@ -62,3 +62,23 @@ fn a_long_thread_cuts_without_a_deep_tree() {
|
||||
let v0 = PI * r * r * len;
|
||||
assert!(v < v0 * 0.95 && v > v0 * 0.6, "{v} of {v0}");
|
||||
}
|
||||
|
||||
/// What lies past each end of a shaft's face: the d16 collar under it, the air over its cap.
|
||||
#[test]
|
||||
fn material_past_the_ends_of_a_shaft_face() {
|
||||
let k = Vec3::new(0.0, 0.0, 1.0);
|
||||
let collar = cylinder(Vec3::ZERO, k, 8.0, 10.0).unwrap();
|
||||
let shaft = cylinder(Vec3::new(0.0, 0.0, 10.0), k, 5.0, 25.0).unwrap();
|
||||
let part = crate::boolean(&collar, &shaft, BoolOp::Union).unwrap().unwrap();
|
||||
let c = crate::cylinder_face_at(&part, Vec3::new(5.0, 0.0, 20.0)).unwrap();
|
||||
assert!((c.radius - 5.0).abs() < 1e-9 && c.start < c.end, "{c:?}");
|
||||
let at = |z: f64| (z - c.axis_point.z) / c.axis.z;
|
||||
let (lo, hi) = (at(10.0).min(at(35.0)), at(10.0).max(at(35.0)));
|
||||
assert!((lo - c.start).abs() < 1e-6 && (hi - c.end).abs() < 1e-6, "{c:?}");
|
||||
let below = if c.axis.z > 0.0 { (c.start - 2.25, c.start - 1.5) } else { (c.end + 1.5, c.end + 2.25) };
|
||||
let above = if c.axis.z > 0.0 { (c.end + 1.5, c.end + 2.25) } else { (c.start - 2.25, c.start - 1.5) };
|
||||
assert!(crate::material_in_ring(&part, &c, 4.19, 5.0, below).unwrap(), "the collar is past the shaft's bottom end");
|
||||
assert!(!crate::material_in_ring(&part, &c, 4.19, 5.0, above).unwrap(), "nothing is past its top end");
|
||||
// Along the face itself the ring is the shaft's own material.
|
||||
assert!(crate::material_in_ring(&part, &c, 4.19, 4.99, (lo + 5.0, lo + 6.0)).unwrap());
|
||||
}
|
||||
|
||||
@@ -353,6 +353,28 @@ impl CylinderFace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any of `b`'s material lies in the ring between radii `r_in` and `r_out` about the
|
||||
/// face's axis, over `along` (an interval of the axis coordinate the face's `start` and `end`
|
||||
/// are in): the exact intersection of the body with that ring, by an SMLib boolean.
|
||||
///
|
||||
/// A thread asks this past a face's end: a free end has nothing there (a cap, a chamfer the
|
||||
/// groove runs out through), a shoulder, a head or a blind hole's bottom has.
|
||||
pub fn material_in_ring(b: &Body, c: &CylinderFace, r_in: f64, r_out: f64, along: (f64, f64)) -> Result<bool> {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let (a0, a1) = along;
|
||||
if !(r_in >= 0.0 && r_out > r_in && a1 > a0 && r_out.is_finite() && a0.is_finite() && a1.is_finite()) {
|
||||
return Err(crate::KernelError::Invalid(format!("ring r {r_in}..{r_out} over {a0}..{a1}")));
|
||||
}
|
||||
let plane =
|
||||
Plane::from_normal(c.axis_point + c.axis * a0, *c.axis).ok_or_else(|| crate::KernelError::Invalid("ring axis".into()))?;
|
||||
// Circles started at the primitives' odd seam angle, off the frame's axes (as `cylinder`).
|
||||
let seam = crate::build::SEAM_ANGLE;
|
||||
let holes = if r_in > 0.0 { vec![Loop2::circle_from(Vec2::ZERO, r_in, seam).reversed()] } else { Vec::new() };
|
||||
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, r_out, seam), holes };
|
||||
let ring = crate::extrude(&plane, &[region], 0.0, a1 - a0)?.pop().ok_or_else(|| crate::KernelError::Failed("ring".into()))?;
|
||||
Ok(crate::boolean(b, &ring, crate::BoolOp::Intersect)?.is_some())
|
||||
}
|
||||
|
||||
/// The cylindrical face one of whose end circles is centred at `p` (a circle given by its
|
||||
/// centre, where no face lies), the nearest when several are.
|
||||
pub fn cylinder_end_at(b: &Body, p: Vec3) -> Option<CylinderFace> {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled M10 thread over 15 of the 30 mm: it stops square in the middle of the face",
|
||||
"source": "invariants",
|
||||
"method": "the square stop has no closed form; one valid body inside the stock's box",
|
||||
"what": "a modelled M10 thread over 15 of the 30 mm from the top end (`from`): it stops square in the middle of the face",
|
||||
"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. ; the thread runs from the top end (z=30, out past it into the air) down to z=15, where it stops square: there the groove's section is symmetric about its centre line, so the sweep's last section cuts behind the stop what it leaves ahead of it, and the stop removes exactly as if the groove ended at z=15: V = pi 5^2 15 + 2 pi int_15^30 int r m(r) dr dz. The centre of mass is on the axis (ten whole turns; the stop's pieces are under 0.02 mm off it) at z = (pi 25 15 x 7.5 + V_threaded x 22.5) / V; the stop's first moment, O(w^2), moves it under 0.002 mm. A pick at z=15 alone would be refused: both ends are free. Tolerance 0.05%: the groove is SMLib's exact screw sweep",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 2155.220203721,
|
||||
"center_of_mass": [
|
||||
0,
|
||||
0,
|
||||
14.300624991390395
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
-5,
|
||||
@@ -15,5 +21,8 @@
|
||||
5,
|
||||
30
|
||||
]
|
||||
},
|
||||
"tolerance": {
|
||||
"rel": 0.0005
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@
|
||||
],
|
||||
"modeled": true,
|
||||
"designation": "M10",
|
||||
"length": 15
|
||||
"length": 15,
|
||||
"from": [
|
||||
0,
|
||||
0,
|
||||
30
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled M10 thread on a d10 shaft running down to a d16 shoulder",
|
||||
"source": "invariants",
|
||||
"method": "the thread must end at the shoulder, leaving the d16 collar whole; no closed form for the end; one valid body",
|
||||
"what": "a modelled M10 thread on a d10 shaft running down to a d16 shoulder: it stops clear of it, the collar whole",
|
||||
"source": "analytic",
|
||||
"method": "the shaft's lower end is a shoulder (the d16 collar lies past it, in the groove's way), so the groove stops there with its widest part clear of it: its centre w = (P/4 + 2 (d/2 + 0.05P - r_root) tan30)/2 = 0.699551 mm above the shoulder (its half width at the tool's outer edge, 0.05 P past the crest), and the square stop inside material removes exactly as if the groove ended at its centre (see 003); the top end is free, the groove runs out into the air. V = pi 8^2 10 (the collar) + pi 5^2 w + 2 pi int_{10+w}^35 int r m(r) dr dz (m as in 001); the centre of mass from the three parts' moments (the threaded part's at its middle, the screw averaging every meridian alike; its stop moves it under 0.002 mm). Tolerance 0.05%: the groove is SMLib's exact screw sweep; a groove running a pitch into the collar (the old free-end overrun) removes 0.9% more",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 3648.530350498,
|
||||
"center_of_mass": [
|
||||
0,
|
||||
0,
|
||||
12.824948457564597
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
-8,
|
||||
@@ -15,5 +21,8 @@
|
||||
8,
|
||||
35
|
||||
]
|
||||
},
|
||||
"tolerance": {
|
||||
"rel": 0.0005
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"sources": [],
|
||||
"what": "a modelled M10 thread on a d10 shaft whose root at the d16 shoulder has an r1 fillet",
|
||||
"source": "invariants",
|
||||
"method": "thread meeting a concave fillet; one valid body",
|
||||
"what": "a modelled M10 thread on a d10 shaft whose root at the d16 shoulder has an r1 fillet: it stops clear of the fillet",
|
||||
"source": "analytic",
|
||||
"method": "the fillet's ring: the corner square [5,6]x[10,11] less the quarter disc about (6,11), V_f = 2 pi (5.5 - pi/4 (6 - 4/(3 pi))), its centroid z from the same moments; the cylinder face starts at z=11, where the fillet meets it. the shaft's lower end (z=11) is a shoulder (the fillet and the collar lies past it, in the groove's way), so the groove stops there with its widest part clear of it: its centre w = (P/4 + 2 (d/2 + 0.05P - r_root) tan30)/2 = 0.699551 mm above the shoulder (its half width at the tool's outer edge, 0.05 P past the crest), and the square stop inside material removes exactly as if the groove ended at its centre (see 003); the top end is free, the groove runs out into the air. V = pi 8^2 10 + V_f + pi 5^2 (1 + w) + 2 pi int_{11+w}^35 int r m(r) dr dz. Tolerance 0.05%: SMLib's exact screw sweep",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 3668.971737351,
|
||||
"center_of_mass": [
|
||||
0,
|
||||
0,
|
||||
12.813982957360412
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
-8,
|
||||
@@ -15,5 +21,8 @@
|
||||
8,
|
||||
35
|
||||
]
|
||||
},
|
||||
"tolerance": {
|
||||
"rel": 0.0005
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"sources": [],
|
||||
"what": "an M10 thread that vanishes over a 2-pitch runout before a shoulder",
|
||||
"source": "invariants",
|
||||
"method": "a runout would let 004 end without cutting the shoulder",
|
||||
"method": "a runout would let 004's thread vanish before the shoulder instead of stopping square",
|
||||
"missing": "solid.thread has no runout (vanishing thread) length"
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
],
|
||||
"what": "rounding the top edges of a hex head on a shaft with a modelled thread (it froze the app)",
|
||||
"source": "analytic",
|
||||
"method": "ISO 68-1 basic profile: H = P sqrt3/2, basic depth 5H/8. External groove (the space between teeth): a trapezoid P/4 wide at the root radius d/2 - 5H/8, widening by 2 tan30 per unit of radius to 7P/8 at d/2. Internal groove: P/8 wide at the major radius d/2, widening to 3P/4 at the minor radius, cut only where there is material (r >= hole radius). Pappus: volume per turn = 2 pi A rc (A the trapezoid area, rc its centroid radius from the axis); a helical sweep of L/P turns removes exactly (L/P) x that, where each end of the thread is a free end or lies inside material. Shaft D5 h=10 plus a hexagonal head of apothem a=2 (area 2 sqrt3 a^2 = 13.8564), h=3. M5 (P=0.8) over 4 mm from the free bottom end (5 turns): 5 x 2.84480. The six top edges rounded R=r=0.3 with the vertical edges left sharp: the section at depth y below the top is a hexagon of apothem a - d(y), d = r - sqrt(r^2 - (r-y)^2); removed = 2 sqrt3 int (2 a d - d^2) dy = 2 sqrt3 [2 a r^2 (1 - pi/4) - r^3 (5/3 - pi/2)] = 0.25866. V = pi 2.5^2 10 + 41.5692 - 5 x 2.84480 - 0.25866 = 223.4361 mm3. Tolerance 1%: a modelled thread is a faceted helical solid (radial chord error up to 1% of the pitch on every wall, i.e. a volume deficit of about 4/3 x sag/R).",
|
||||
"method": "ISO 68-1 basic profile: H = P sqrt3/2, basic depth 5H/8. External groove (the space between teeth): a trapezoid P/4 wide at the root radius d/2 - 5H/8, widening by 2 tan30 per unit of radius to 7P/8 at d/2. Internal groove: P/8 wide at the major radius d/2, widening to 3P/4 at the minor radius, cut only where there is material (r >= hole radius). Pappus: volume per turn = 2 pi A rc (A the trapezoid area, rc its centroid radius from the axis); a helical sweep of L/P turns removes exactly (L/P) x that, where each end of the thread is a free end or lies inside material. Shaft D5 h=10 plus a hexagonal head of apothem a=2 (area 2 sqrt3 a^2 = 13.8564), h=3. M5 (P=0.8) over 4 mm from the free bottom end (5 turns): 5 x 2.84480. The pick (z=5) is mid-face: the thread starts at the face's only free end. The top end (z=10) is not free: the hex's corners (2.3094 mm out) lie past the M5 root (2.0670 mm), in the groove's way, so the groove never reaches the head (it used to, from the head end, cutting 1.17 mm into it and clipping its corners into the groove's flank); the stop at z=4 is square inside the shaft, removing exactly 4/P turns (the section is symmetric about its centre line). The six top edges rounded R=r=0.3 with the vertical edges left sharp: the section at depth y below the top is a hexagon of apothem a - d(y), d = r - sqrt(r^2 - (r-y)^2); removed = 2 sqrt3 int (2 a d - d^2) dy = 2 sqrt3 [2 a r^2 (1 - pi/4) - r^3 (5/3 - pi/2)] = 0.25866. V = pi 2.5^2 10 + 41.5692 - 5 x 2.84480 - 0.25866 = 223.4361 mm3. Tolerance 0.02%: the groove is SMLib's exact screw sweep (a groove run from the head end instead removes 0.2 mm3 of the head, 0.09%).",
|
||||
"outcome": "solid",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 223.4361,
|
||||
"tolerance": {
|
||||
"rel": 0.01
|
||||
"rel": 0.0002
|
||||
},
|
||||
"bbox": {
|
||||
"min": [
|
||||
|
||||
@@ -341,13 +341,39 @@ case(T6, "001-external-m10-full", "a modelled M10 thread over the whole of a d10
|
||||
case(T6, "002-external-m10x1-fine", "a modelled fine M10x1 thread over a d10 x 30 cylinder", cylT + [thr([5, 0, 15], designation="M10x1")],
|
||||
{"body_count": 1, "volume_mm3": ext_volume(10, 1.0, [(0, 30, lambda z: 5)]), "tolerance": TOL_T},
|
||||
TMETH + "; P = 1, L = 30")
|
||||
case(T6, "003-external-m10-partial-length", "a modelled M10 thread over 15 of the 30 mm: it stops square in the middle of the face", cylT + [thr([5, 0, 15], designation="M10", length=15)],
|
||||
{"body_count": 1, "bbox": {"min": [-5, -5, 0], "max": [5, 5, 30]}},
|
||||
"the square stop has no closed form; one valid body inside the stock's box", source="invariants")
|
||||
# A square stop inside the material takes exactly length/P turns: the groove's section is symmetric
|
||||
# about its centre line, so what the sweep's last section leaves uncut ahead of the stop it cuts
|
||||
# behind it, in mirror image. Its first moment does not cancel, by under 0.002 mm of the centre here.
|
||||
v3 = PI * 25 * 15 + ext_volume(10, 1.5, [(15, 30, lambda z: 5)])
|
||||
z3 = (PI * 25 * 15 * 7.5 + ext_volume(10, 1.5, [(15, 30, lambda z: 5)]) * 22.5) / v3
|
||||
case(T6, "003-external-m10-partial-length", "a modelled M10 thread over 15 of the 30 mm from the top end (`from`): it stops square in the middle of the face",
|
||||
cylT + [thr([5, 0, 15], designation="M10", length=15, **{"from": [0, 0, 30]})],
|
||||
{"body_count": 1, "volume_mm3": v3, "center_of_mass": [0, 0, z3], "bbox": {"min": [-5, -5, 0], "max": [5, 5, 30]}, "tolerance": {"rel": 5e-4}},
|
||||
TMETH.replace("Tolerance 0.5%: solid.thread cuts faceted by design (chords within P/200 on the body, P/100 on the tool)", "") +
|
||||
"; the thread runs from the top end (z=30, out past it into the air) down to z=15, where it stops square: there the groove's section is symmetric about its centre line, "
|
||||
"so the sweep's last section cuts behind the stop what it leaves ahead of it, and the stop removes exactly as if the groove ended at z=15: "
|
||||
"V = pi 5^2 15 + 2 pi int_15^30 int r m(r) dr dz. The centre of mass is on the axis (ten whole turns; the stop's pieces are under 0.02 mm off it) at "
|
||||
"z = (pi 25 15 x 7.5 + V_threaded x 22.5) / V; the stop's first moment, O(w^2), moves it under 0.002 mm. A pick at z=15 alone would be refused: both ends are free. "
|
||||
"Tolerance 0.05%: the groove is SMLib's exact screw sweep")
|
||||
shaft = [cyl([0, 0, 0], 8, 10, body_name="Part"), cyl([0, 0, 10], 5, 25, operation="join", targets=["Part"])]
|
||||
case(T6, "004-external-m10-to-shoulder", "a modelled M10 thread on a d10 shaft running down to a d16 shoulder", shaft + [thr([5, 0, 25], designation="M10")],
|
||||
{"body_count": 1, "bbox": {"min": [-8, -8, 0], "max": [8, 8, 35]}},
|
||||
"the thread must end at the shoulder, leaving the d16 collar whole; no closed form for the end; one valid body", source="invariants")
|
||||
# At a shoulder the groove stops with its widest part clear of it: its centre a half groove width w short
|
||||
# of the shoulder, w taken at the tool's outer edge 0.05 P past the crest (the section reaches past the
|
||||
# surface so the cut leaves no skin). The collar is whole.
|
||||
def ext_half_width(d, P):
|
||||
H = math.sqrt(3) / 2 * P
|
||||
return (P / 4 + 2 * (d / 2 + 0.05 * P - (d / 2 - 5 * H / 8)) * math.tan(PI / 6)) / 2
|
||||
w10 = ext_half_width(10, 1.5)
|
||||
SHOULDER = ("the shaft's lower end is a shoulder (the d16 collar lies past it, in the groove's way), so the groove stops there with its widest part clear of it: "
|
||||
"its centre w = (P/4 + 2 (d/2 + 0.05P - r_root) tan30)/2 = %.6f mm above the shoulder (its half width at the tool's outer edge, 0.05 P past the crest), "
|
||||
"and the square stop inside material removes exactly as if the groove ended at its centre (see 003); the top end is free, the groove runs out into the air. " % w10)
|
||||
vt4 = ext_volume(10, 1.5, [(10 + w10, 35, lambda z: 5)])
|
||||
v4 = PI * 64 * 10 + PI * 25 * w10 + vt4
|
||||
z4 = (PI * 64 * 10 * 5 + PI * 25 * w10 * (10 + w10 / 2) + vt4 * (10 + w10 + 35) / 2) / v4
|
||||
case(T6, "004-external-m10-to-shoulder", "a modelled M10 thread on a d10 shaft running down to a d16 shoulder: it stops clear of it, the collar whole", shaft + [thr([5, 0, 25], designation="M10")],
|
||||
{"body_count": 1, "volume_mm3": v4, "center_of_mass": [0, 0, z4], "bbox": {"min": [-8, -8, 0], "max": [8, 8, 35]}, "tolerance": {"rel": 5e-4}},
|
||||
SHOULDER + "V = pi 8^2 10 (the collar) + pi 5^2 w + 2 pi int_{10+w}^35 int r m(r) dr dz (m as in 001); the centre of mass from the three parts' moments (the threaded part's at its "
|
||||
"middle, the screw averaging every meridian alike; its stop moves it under 0.002 mm). Tolerance 0.05%: the groove is SMLib's exact screw sweep; a groove running a pitch into the collar "
|
||||
"(the old free-end overrun) removes 0.9% more")
|
||||
dI, PI_ = 10, 1.5
|
||||
g = iso_groove_fraction_int(dI, PI_)
|
||||
vg = 20 * 2 * PI * quad(lambda r: r * g(r), 4.25, 5)
|
||||
@@ -359,14 +385,23 @@ case(T6, "006-external-m10-into-chamfer", "a d10 x 30 cylinder with a 1 mm chamf
|
||||
cylT + [C("solid.chamfer", edges=[[5, 0, 30]], distance=1), thr([5, 0, 15], designation="M10")],
|
||||
{"body_count": 1, "volume_mm3": ext_volume(10, 1.5, [(0, 29, lambda z: 5), (29, 30, lambda z: 5 - (z - 29))]), "tolerance": TOL_T},
|
||||
TMETH + "; the stock's section is r <= 5 below z=29 and r <= 5 - (z-29) in the chamfer; the groove runs one pitch past z=29, which covers the chamfer down to r=4.2 (above the root r=4.188 only for 0.01 mm)")
|
||||
case(T6, "007-external-m10-at-filleted-shoulder", "a modelled M10 thread on a d10 shaft whose root at the d16 shoulder has an r1 fillet",
|
||||
# The r1 fillet at the shoulder adds the ring of the corner square [5,6]x[10,11] less the quarter disc about
|
||||
# (6, 11): 2 pi (1 x 5.5 - pi/4 (6 - 4/(3 pi))). The cylinder face then starts at z=11.
|
||||
vf7 = 2 * PI * (5.5 - PI / 4 * (6 - 4 / (3 * PI)))
|
||||
zf7 = 2 * PI * (5.5 * 10.5 - PI / 4 * (6 - 4 / (3 * PI)) * (11 - 4 / (3 * PI))) / vf7
|
||||
vt7 = ext_volume(10, 1.5, [(11 + w10, 35, lambda z: 5)])
|
||||
v7 = PI * 64 * 10 + vf7 + PI * 25 * (1 + w10) + vt7
|
||||
z7 = (PI * 64 * 10 * 5 + vf7 * zf7 + PI * 25 * (1 + w10) * (10 + (1 + w10) / 2) + vt7 * (11 + w10 + 35) / 2) / v7
|
||||
case(T6, "007-external-m10-at-filleted-shoulder", "a modelled M10 thread on a d10 shaft whose root at the d16 shoulder has an r1 fillet: it stops clear of the fillet",
|
||||
shaft + [C("solid.fillet", edges=[[5, 0, 10]], radius=1), thr([5, 0, 25], designation="M10")],
|
||||
{"body_count": 1, "bbox": {"min": [-8, -8, 0], "max": [8, 8, 35]}},
|
||||
"thread meeting a concave fillet; one valid body", source="invariants")
|
||||
{"body_count": 1, "volume_mm3": v7, "center_of_mass": [0, 0, z7], "bbox": {"min": [-8, -8, 0], "max": [8, 8, 35]}, "tolerance": {"rel": 5e-4}},
|
||||
"the fillet's ring: the corner square [5,6]x[10,11] less the quarter disc about (6,11), V_f = 2 pi (5.5 - pi/4 (6 - 4/(3 pi))), its centroid z from the same moments; "
|
||||
"the cylinder face starts at z=11, where the fillet meets it. " + SHOULDER.replace("lower end is a shoulder (the d16 collar", "lower end (z=11) is a shoulder (the fillet and the collar") +
|
||||
"V = pi 8^2 10 + V_f + pi 5^2 (1 + w) + 2 pi int_{11+w}^35 int r m(r) dr dz. Tolerance 0.05%: SMLib's exact screw sweep")
|
||||
missing(T6, "008-multi-start-thread", "a two-start M10 thread (lead 3, pitch 1.5)", "solid.thread has no start count: multi-start threads",
|
||||
"would be graded as 001 with the material fraction of two grooves per lead")
|
||||
missing(T6, "009-thread-runout-length", "an M10 thread that vanishes over a 2-pitch runout before a shoulder", "solid.thread has no runout (vanishing thread) length",
|
||||
"a runout would let 004 end without cutting the shoulder")
|
||||
"a runout would let 004's thread vanish before the shoulder instead of stopping square")
|
||||
case(T6, "010-part-threaded-boss", "the running part with a modelled M16 thread on its d16 boss",
|
||||
[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")
|
||||
|
||||
Reference in New Issue
Block a user