mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Tapped Hole: verified metric fine, UNC/UNF and parallel G/Rp catalogues (#4178)
* feat: expand tapped-hole catalogue with metric fine and Unified sizes * docs: distinguish metric designations from expanded tapped catalogue * feat: add internal pipe catalogue and checked Rc profile data * Connect parallel G and Rp catalogues to cylindrical tapped holes * fix(i18n): register tapped thread catalogue labels * fix(i18n): complete thread labels in required locales
This commit is contained in:
committed by
GitHub
parent
9a737bd63c
commit
19db763899
@@ -280,6 +280,7 @@
|
||||
"Local library": "Local library",
|
||||
"Loop": "Loop",
|
||||
"M5 or M5x0.5": "M5 or M5x0.5",
|
||||
"M5, M5x0.5, 1/4-20 UNC": "M5, M5x0.5, 1/4-20 UNC",
|
||||
"MANAGE": "MANAGE",
|
||||
"MATERIALS": "MATERIALS",
|
||||
"MODEL": "MODEL",
|
||||
@@ -518,6 +519,7 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "This child is shared. Position changes apply to every copy of its parent.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.",
|
||||
"Thread diameter": "Thread diameter",
|
||||
"To": "To",
|
||||
"To Offset": "To Offset",
|
||||
"To edges": "To edges",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"Local library": "ローカルライブラリ",
|
||||
"Loop": "ループ",
|
||||
"M5 or M5x0.5": "M5 または M5x0.5",
|
||||
"M5, M5x0.5, 1/4-20 UNC": "M5, M5x0.5, 1/4-20 UNC",
|
||||
"MANAGE": "管理",
|
||||
"MATERIALS": "材料",
|
||||
"MODEL": "モデル",
|
||||
@@ -518,6 +519,7 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "この子要素は共有されています。位置の変更は親のすべてのコピーに適用されます。",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "この画像はまだGitHubに添付されていません。コピーまたは保存し、GitHubで添付してください。",
|
||||
"Thread diameter": "ねじ径",
|
||||
"To": "終了",
|
||||
"To Offset": "オフセットまで",
|
||||
"To edges": "終了側エッジ",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"Local library": "本地库",
|
||||
"Loop": "循环",
|
||||
"M5 or M5x0.5": "M5 或 M5x0.5",
|
||||
"M5, M5x0.5, 1/4-20 UNC": "M5, M5x0.5, 1/4-20 UNC",
|
||||
"MANAGE": "管理",
|
||||
"MATERIALS": "材料",
|
||||
"MODEL": "模型",
|
||||
@@ -518,6 +519,7 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "此子项是共享的。位置更改会应用到其父项的每个副本。",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "此图片尚未附加到GitHub。请复制或保存图片,然后在GitHub中附加。",
|
||||
"Thread diameter": "螺纹直径",
|
||||
"To": "终点",
|
||||
"To Offset": "偏移",
|
||||
"To edges": "终止边",
|
||||
|
||||
+74
-13
@@ -381,7 +381,7 @@ fn thread_for(diameter: f64, internal: bool) -> Option<(f64, f64)> {
|
||||
.map(|(n, p, _)| (n, p))
|
||||
}
|
||||
|
||||
fn format_thread(d: f64, p: f64) -> String {
|
||||
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)) }
|
||||
@@ -396,6 +396,20 @@ pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result<String> {
|
||||
/// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
// Tapped holes admit verified Unified and parallel pipe sizes; the separate Thread feature remains metric,
|
||||
// including when an untrusted design requests modelled geometry.
|
||||
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
|
||||
.bodies
|
||||
.iter()
|
||||
@@ -406,21 +420,33 @@ fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>,
|
||||
})
|
||||
.ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
|
||||
let dia = cyl.radius * 2.0;
|
||||
let (major, pitch) = 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")))?,
|
||||
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 fits = if cyl.internal {
|
||||
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")))?,
|
||||
},
|
||||
};
|
||||
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
|
||||
} else {
|
||||
(major - dia).abs() < 0.15 * major || (turned && dia > major)
|
||||
};
|
||||
if !fits {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"an {} thread does not fit a {dia:.2} mm {}",
|
||||
format_thread(major, pitch),
|
||||
if cyl.internal { "hole" } else { "shaft" }
|
||||
)));
|
||||
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);
|
||||
@@ -438,7 +464,7 @@ fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>,
|
||||
Ok(ThreadInfo {
|
||||
feature,
|
||||
body,
|
||||
designation: format_thread(major, pitch),
|
||||
designation: name,
|
||||
major_diameter: major,
|
||||
pitch,
|
||||
internal: cyl.internal,
|
||||
@@ -3764,6 +3790,36 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
|
||||
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
|
||||
// pocket and keeps metadata attached to the narrow cylindrical bore.
|
||||
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
|
||||
let tol = (radius * 1e-6).max(1e-6);
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for body in &st.bodies {
|
||||
let mesh = body.mesh();
|
||||
for face in 0..body.body.face_count() {
|
||||
let Some(c) = kernel::cylinder_face(&body.body, &mesh, face) else { continue };
|
||||
let offset = entry - c.axis_point;
|
||||
if !c.internal
|
||||
|| (c.radius - radius).abs() > tol
|
||||
|| c.axis.dot(inward).abs() < 1.0 - 1e-8
|
||||
|| (offset - c.axis * offset.dot(c.axis)).len() > tol
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let a = (c.axis_point + c.axis * c.start - entry).dot(inward);
|
||||
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
|
||||
let lo = a.min(b).max(0.0);
|
||||
let hi = a.max(b);
|
||||
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
|
||||
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, wall)| wall)
|
||||
}
|
||||
|
||||
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
|
||||
/// straight cylinder (see [`hole_tools_and_prisms`]).
|
||||
fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
|
||||
@@ -6238,9 +6294,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if let Some(d) = thread {
|
||||
// A point on each hole's wall, a little below the entry.
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
let parallel_pipe = crate::pipe_thread_catalog::parse_pipe_thread(d).is_some();
|
||||
for p in spots {
|
||||
let wall = p + dir.any_perp() * r + dir * (r * 0.5);
|
||||
let t = thread_on(st, f.id, wall, Some(d), None, false)?;
|
||||
let wall = if parallel_pipe {
|
||||
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
|
||||
} else {
|
||||
p + dir.any_perp() * r + dir * (r * 0.5)
|
||||
};
|
||||
let t = thread_on_impl(st, f.id, wall, Some(d), None, true, false)?;
|
||||
st.threads.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Tapped Hole designations admit finite G/BSPP and Rp catalogues with explicit cylindrical
|
||||
//! pilot-drill diameters. The existing designation string and authored diameter expression
|
||||
//! are retained. Old metric/Unified/Custom holes require no rewrite; older readers refuse
|
||||
//! this newer format. Rc and other tapered pipe holes are not supported.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Tapped Hole designations now admit a finite UNC/UNF catalogue. The existing string and
|
||||
//! explicit drill-diameter expression are saved; old metric features need no rewrite and
|
||||
//! retain their authored drill. Older releases refuse the newer format rather than treating
|
||||
//! a Unified designation as metric. The separate Thread feature remains metric-only.
|
||||
@@ -44,9 +44,12 @@ pub mod naming;
|
||||
mod params;
|
||||
pub mod physical_material;
|
||||
mod piece_names;
|
||||
pub mod pipe_thread_catalog;
|
||||
mod points;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
mod thread_catalog;
|
||||
pub use thread_catalog::{TappedThread, TappedThreadFamily, parse_tapped_thread, tapped_thread_sizes};
|
||||
mod walk;
|
||||
pub use profile_refs::{capture_profile_selection, profile_fallback_notes, profile_selection_indices, profile_selection_indices_lenient};
|
||||
pub mod plastic;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Finite parallel internal pipe-thread data, in millimetres.
|
||||
//! Data: Yamawa YEU23, printed pp.668 and707:
|
||||
//! https://www.yamawa.eu/files/Yamawa_European_Catalogue_YEU23.pdf
|
||||
//! G and Rp process recommendations are separate from basic thread dimensions.
|
||||
//! G/BSPP and Rp are distinct families with cylindrical cutting-tap bores.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PipeFamily {
|
||||
G,
|
||||
Rp,
|
||||
}
|
||||
impl PipeFamily {
|
||||
pub const ALL: [Self; 2] = [Self::G, Self::Rp];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::G => "G",
|
||||
Self::Rp => "Rp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PipeSize {
|
||||
pub nominal: &'static str,
|
||||
pub tpi: u16,
|
||||
pub major: f64,
|
||||
pub pitch_diameter: f64,
|
||||
/// Basic minor diameter. This is separate from the process-drill recommendation.
|
||||
pub minor: f64,
|
||||
pub g_drill: f64,
|
||||
pub g_bore: f64,
|
||||
pub rp_drill: f64,
|
||||
pub rp_bore: f64,
|
||||
}
|
||||
|
||||
const SIZES: &[PipeSize] = &[
|
||||
PipeSize {
|
||||
nominal: "1/8",
|
||||
tpi: 28,
|
||||
major: 9.728,
|
||||
pitch_diameter: 9.147,
|
||||
minor: 8.566,
|
||||
g_drill: 8.75,
|
||||
g_bore: 8.78,
|
||||
rp_drill: 8.5,
|
||||
rp_bore: 8.55,
|
||||
},
|
||||
PipeSize {
|
||||
nominal: "1/4",
|
||||
tpi: 19,
|
||||
major: 13.157,
|
||||
pitch_diameter: 12.301,
|
||||
minor: 11.445,
|
||||
g_drill: 11.75,
|
||||
g_bore: 11.78,
|
||||
rp_drill: 11.4,
|
||||
rp_bore: 11.5,
|
||||
},
|
||||
PipeSize {
|
||||
nominal: "3/8",
|
||||
tpi: 19,
|
||||
major: 16.662,
|
||||
pitch_diameter: 15.806,
|
||||
minor: 14.95,
|
||||
g_drill: 15.25,
|
||||
g_bore: 15.28,
|
||||
rp_drill: 14.8,
|
||||
rp_bore: 14.9,
|
||||
},
|
||||
PipeSize {
|
||||
nominal: "1/2",
|
||||
tpi: 14,
|
||||
major: 20.955,
|
||||
pitch_diameter: 19.793,
|
||||
minor: 18.631,
|
||||
g_drill: 19.0,
|
||||
g_bore: 19.04,
|
||||
rp_drill: 18.5,
|
||||
rp_bore: 18.55,
|
||||
},
|
||||
PipeSize {
|
||||
nominal: "3/4",
|
||||
tpi: 14,
|
||||
major: 26.441,
|
||||
pitch_diameter: 25.279,
|
||||
minor: 24.117,
|
||||
g_drill: 24.5,
|
||||
g_bore: 24.52,
|
||||
rp_drill: 24.0,
|
||||
rp_bore: 24.1,
|
||||
},
|
||||
PipeSize {
|
||||
nominal: "1",
|
||||
tpi: 11,
|
||||
major: 33.249,
|
||||
pitch_diameter: 31.77,
|
||||
minor: 30.291,
|
||||
g_drill: 30.75,
|
||||
g_bore: 30.77,
|
||||
rp_drill: 30.2,
|
||||
rp_bore: 30.25,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PipeThread {
|
||||
family: PipeFamily,
|
||||
size: &'static PipeSize,
|
||||
}
|
||||
impl PipeThread {
|
||||
pub fn family(self) -> PipeFamily {
|
||||
self.family
|
||||
}
|
||||
pub fn size(self) -> &'static PipeSize {
|
||||
self.size
|
||||
}
|
||||
pub fn designation(self) -> String {
|
||||
format!("{} {}", self.family.label(), self.size.nominal)
|
||||
}
|
||||
pub fn pitch(self) -> f64 {
|
||||
25.4 / f64::from(self.size.tpi)
|
||||
}
|
||||
pub fn profile_degrees(self) -> f64 {
|
||||
55.0
|
||||
}
|
||||
/// Pilot drill and recommended process bore, both from the process table.
|
||||
pub fn process_bore(self) -> (f64, f64) {
|
||||
match self.family {
|
||||
PipeFamily::G => (self.size.g_drill, self.size.g_bore),
|
||||
PipeFamily::Rp => (self.size.rp_drill, self.size.rp_bore),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pipe_thread_sizes() -> impl Iterator<Item = PipeThread> {
|
||||
PipeFamily::ALL.into_iter().flat_map(|family| SIZES.iter().map(move |size| PipeThread { family, size }))
|
||||
}
|
||||
|
||||
pub fn parse_pipe_thread(text: &str) -> Option<PipeThread> {
|
||||
if text.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
let text = text.trim();
|
||||
let (family, nominal) = if text.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("BSPP")) {
|
||||
(PipeFamily::G, text.get(4..)?)
|
||||
} else if text.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("Rp")) {
|
||||
(PipeFamily::Rp, text.get(2..)?)
|
||||
} else if text.get(..1).is_some_and(|p| p.eq_ignore_ascii_case("G")) {
|
||||
(PipeFamily::G, text.get(1..)?)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let size = SIZES.iter().find(|s| s.nominal == nominal.trim())?;
|
||||
Some(PipeThread { family, size })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn dimensions_and_process_references_match_the_source_tables() {
|
||||
for (nominal, tpi, major, pitch, minor, gd, gb, rd, rb) in [
|
||||
("1/8", 28, 9.728, 9.147, 8.566, 8.75, 8.78, 8.5, 8.55),
|
||||
("1/4", 19, 13.157, 12.301, 11.445, 11.75, 11.78, 11.4, 11.5),
|
||||
("3/8", 19, 16.662, 15.806, 14.950, 15.25, 15.28, 14.8, 14.9),
|
||||
("1/2", 14, 20.955, 19.793, 18.631, 19.0, 19.04, 18.5, 18.55),
|
||||
("3/4", 14, 26.441, 25.279, 24.117, 24.5, 24.52, 24.0, 24.1),
|
||||
("1", 11, 33.249, 31.770, 30.291, 30.75, 30.77, 30.2, 30.25),
|
||||
] {
|
||||
for family in PipeFamily::ALL {
|
||||
let thread = parse_pipe_thread(&format!("{} {nominal}", family.label())).unwrap();
|
||||
let s = thread.size();
|
||||
assert_eq!((s.tpi, s.major, s.pitch_diameter, s.minor), (tpi, major, pitch, minor));
|
||||
assert_eq!(
|
||||
thread.process_bore(),
|
||||
match family {
|
||||
PipeFamily::G => (gd, gb),
|
||||
PipeFamily::Rp => (rd, rb),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn recognizes_internal_pipe_designations() {
|
||||
assert_eq!(pipe_thread_sizes().count(), 12);
|
||||
for family in ["G", "Rp"] {
|
||||
for nominal in ["1/8", "1/4", "3/8", "1/2", "3/4", "1"] {
|
||||
let s = parse_pipe_thread(&format!("{family} {nominal}")).unwrap();
|
||||
assert_eq!(parse_pipe_thread(&s.designation()), Some(s));
|
||||
assert_eq!(s.profile_degrees(), 55.0);
|
||||
}
|
||||
}
|
||||
assert_eq!(parse_pipe_thread("BSPP 1/4").unwrap().designation(), "G 1/4");
|
||||
assert!(parse_pipe_thread(&" ".repeat(81)).is_none());
|
||||
for bad in ["NPT 1/4", "R 1/4", "Rc 6.35", "Rc 2/8", "G 1/4 junk", "Rc NaN", "Rp", "M6", "1/4-20 UNC"] {
|
||||
assert!(parse_pipe_thread(bad).is_none(), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn nominal_labels_and_process_bores_are_distinct() {
|
||||
let g = parse_pipe_thread("G 1/4").unwrap();
|
||||
let rp = parse_pipe_thread("Rp 1/4").unwrap();
|
||||
assert_eq!(g.size().major, 13.157);
|
||||
assert_ne!(g.size().major, 25.4 / 4.0);
|
||||
assert_eq!(g.process_bore(), (11.75, 11.78));
|
||||
assert_eq!(rp.process_bore(), (11.4, 11.5));
|
||||
assert!((g.pitch() - 25.4 / 19.0).abs() < 1e-12);
|
||||
assert!(parse_pipe_thread("Rc 1/4").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Finite tapped-hole catalogue. Units are millimetres internally.
|
||||
//! See docs/thread-catalogue.md for sources and the limits of the drill recommendations.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TappedThreadFamily {
|
||||
Metric,
|
||||
Unc,
|
||||
Unf,
|
||||
G,
|
||||
Rp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TappedThread {
|
||||
pub family: TappedThreadFamily,
|
||||
pub designation: String,
|
||||
pub major_diameter: f64,
|
||||
pub pitch: f64,
|
||||
pub tap_drill: f64,
|
||||
}
|
||||
|
||||
impl TappedThread {
|
||||
pub fn profile_degrees(&self) -> f64 {
|
||||
match self.family {
|
||||
TappedThreadFamily::G | TappedThreadFamily::Rp => 55.0,
|
||||
_ => 60.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pipe_size(size: crate::pipe_thread_catalog::PipeThread) -> TappedThread {
|
||||
use crate::pipe_thread_catalog::PipeFamily;
|
||||
TappedThread {
|
||||
family: match size.family() {
|
||||
PipeFamily::G => TappedThreadFamily::G,
|
||||
PipeFamily::Rp => TappedThreadFamily::Rp,
|
||||
},
|
||||
designation: size.designation(),
|
||||
major_diameter: size.size().major,
|
||||
pitch: size.pitch(),
|
||||
tap_drill: size.process_bore().0,
|
||||
}
|
||||
}
|
||||
|
||||
// Diameter/pitch pairs verified against Guhring, plus Yamawa for M12x1.25.
|
||||
const METRIC_FINE: &[(f64, f64)] = &[
|
||||
(3.0, 0.35),
|
||||
(4.0, 0.5),
|
||||
(5.0, 0.5),
|
||||
(6.0, 0.75),
|
||||
(8.0, 1.0),
|
||||
(10.0, 1.0),
|
||||
(10.0, 1.25),
|
||||
(12.0, 1.0),
|
||||
(12.0, 1.25),
|
||||
(12.0, 1.5),
|
||||
(14.0, 1.5),
|
||||
(16.0, 1.5),
|
||||
(18.0, 1.5),
|
||||
(20.0, 1.5),
|
||||
(24.0, 2.0),
|
||||
];
|
||||
|
||||
// Guhring CUT TAPS approx75% recommendations, not form-tap drills.
|
||||
// Major diameters of numbered screws are from PEM; inch pitches convert exactly by25.4/TPI.
|
||||
const UNIFIED: &[(&str, TappedThreadFamily, f64, u32, f64)] = &[
|
||||
("#4-40 UNC", TappedThreadFamily::Unc, 2.8448, 40, 2.2),
|
||||
("#4-48 UNF", TappedThreadFamily::Unf, 2.8448, 48, 2.32),
|
||||
("#6-32 UNC", TappedThreadFamily::Unc, 3.5052, 32, 2.7051),
|
||||
("#6-40 UNF", TappedThreadFamily::Unf, 3.5052, 40, 2.8702),
|
||||
("#8-32 UNC", TappedThreadFamily::Unc, 4.1656, 32, 3.4),
|
||||
("#8-36 UNF", TappedThreadFamily::Unf, 4.1656, 36, 3.4544),
|
||||
("#10-24 UNC", TappedThreadFamily::Unc, 4.826, 24, 3.7973),
|
||||
("#10-32 UNF", TappedThreadFamily::Unf, 4.826, 32, 4.0386),
|
||||
("#12-24 UNC", TappedThreadFamily::Unc, 5.4864, 24, 4.45),
|
||||
("#12-28 UNF", TappedThreadFamily::Unf, 5.4864, 28, 4.6),
|
||||
("1/4-20 UNC", TappedThreadFamily::Unc, 6.35, 20, 5.1054),
|
||||
("1/4-28 UNF", TappedThreadFamily::Unf, 6.35, 28, 5.45),
|
||||
("5/16-18 UNC", TappedThreadFamily::Unc, 7.9375, 18, 6.5278),
|
||||
("5/16-24 UNF", TappedThreadFamily::Unf, 7.9375, 24, 6.9088),
|
||||
("3/8-16 UNC", TappedThreadFamily::Unc, 9.525, 16, 7.9375),
|
||||
("3/8-24 UNF", TappedThreadFamily::Unf, 9.525, 24, 8.5),
|
||||
("7/16-14 UNC", TappedThreadFamily::Unc, 11.1125, 14, 9.3472),
|
||||
("7/16-20 UNF", TappedThreadFamily::Unf, 11.1125, 20, 9.95),
|
||||
("1/2-13 UNC", TappedThreadFamily::Unc, 12.7, 13, 10.715625),
|
||||
("1/2-20 UNF", TappedThreadFamily::Unf, 12.7, 20, 11.4),
|
||||
("5/8-11 UNC", TappedThreadFamily::Unc, 15.875, 11, 13.6),
|
||||
("5/8-18 UNF", TappedThreadFamily::Unf, 15.875, 18, 14.5),
|
||||
("3/4-10 UNC", TappedThreadFamily::Unc, 19.05, 10, 16.5),
|
||||
("3/4-16 UNF", TappedThreadFamily::Unf, 19.05, 16, 17.5),
|
||||
("7/8-9 UNC", TappedThreadFamily::Unc, 22.225, 9, 19.5),
|
||||
("7/8-14 UNF", TappedThreadFamily::Unf, 22.225, 14, 20.4),
|
||||
("1-8 UNC", TappedThreadFamily::Unc, 25.4, 8, 22.25),
|
||||
("1-12 UNF", TappedThreadFamily::Unf, 25.4, 12, 23.3),
|
||||
];
|
||||
|
||||
fn metric_size(major_diameter: f64, pitch: f64) -> TappedThread {
|
||||
TappedThread {
|
||||
family: TappedThreadFamily::Metric,
|
||||
designation: crate::eval::format_thread(major_diameter, pitch),
|
||||
major_diameter,
|
||||
pitch,
|
||||
// Preserve the existing metric estimate and rounding used by solid.hole.
|
||||
tap_drill: ((major_diameter - pitch) * 1000.0).round() / 1000.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Catalogue choices for tapped holes only. Automatic Thread sizing is unchanged.
|
||||
pub fn tapped_thread_sizes(family: TappedThreadFamily) -> Vec<TappedThread> {
|
||||
if matches!(family, TappedThreadFamily::G | TappedThreadFamily::Rp) {
|
||||
return crate::pipe_thread_catalog::pipe_thread_sizes().map(pipe_size).filter(|s| s.family == family).collect();
|
||||
}
|
||||
|
||||
if family == TappedThreadFamily::Metric {
|
||||
let mut sizes: Vec<_> = crate::metric_thread_sizes().iter().chain(METRIC_FINE).map(|&(d, p)| metric_size(d, p)).collect();
|
||||
sizes.sort_by(|a, b| a.major_diameter.total_cmp(&b.major_diameter).then(b.pitch.total_cmp(&a.pitch)));
|
||||
sizes
|
||||
} else {
|
||||
UNIFIED
|
||||
.iter()
|
||||
.filter(|(_, f, _, _, _)| *f == family)
|
||||
.map(|&(name, family, major_diameter, tpi, tap_drill)| TappedThread {
|
||||
family,
|
||||
designation: name.into(),
|
||||
major_diameter,
|
||||
pitch: 25.4 / f64::from(tpi),
|
||||
tap_drill,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit metric custom sizes remain supported. Unified sizes must be in the verified
|
||||
/// catalogue and include their UNC/UNF suffix. G/Rp use explicit process drills; modeled Thread stays metric-only.
|
||||
pub fn parse_tapped_thread(text: &str) -> Option<TappedThread> {
|
||||
if text.len() > 128 {
|
||||
return None;
|
||||
}
|
||||
if let Some((d, p)) = crate::parse_metric_thread(text) {
|
||||
return Some(metric_size(d, p));
|
||||
}
|
||||
if let Some(size) = crate::pipe_thread_catalog::parse_pipe_thread(text) {
|
||||
return Some(pipe_size(size));
|
||||
}
|
||||
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_uppercase();
|
||||
let &(name, family, major_diameter, tpi, tap_drill) = UNIFIED.iter().find(|row| row.0 == normalized)?;
|
||||
Some(TappedThread { family, designation: name.into(), major_diameter, pitch: 25.4 / f64::from(tpi), tap_drill })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parallel_pipe_catalogue_is_available_to_tapped_holes() {
|
||||
for label in ["G 1/4", "Rp 1/4"] {
|
||||
let s = parse_tapped_thread(label).unwrap();
|
||||
assert_eq!(s.profile_degrees(), 55.0);
|
||||
assert_eq!(s.major_diameter, 13.157);
|
||||
assert_eq!(s.tap_drill, if s.family == TappedThreadFamily::G { 11.75 } else { 11.4 });
|
||||
assert!((s.pitch - 25.4 / 19.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapped_catalogue_has_unique_valid_sizes_and_round_trips() {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for family in [TappedThreadFamily::Metric, TappedThreadFamily::Unc, TappedThreadFamily::Unf, TappedThreadFamily::G, TappedThreadFamily::Rp] {
|
||||
let sizes = tapped_thread_sizes(family);
|
||||
if family != TappedThreadFamily::Metric {
|
||||
assert_eq!(sizes.len(), if matches!(family, TappedThreadFamily::G | TappedThreadFamily::Rp) { 6 } else { 14 });
|
||||
}
|
||||
for size in sizes {
|
||||
assert!(names.insert(size.designation.clone()));
|
||||
assert_eq!(parse_tapped_thread(&size.designation), Some(size.clone()));
|
||||
assert!(size.major_diameter.is_finite() && size.pitch.is_finite() && size.tap_drill.is_finite());
|
||||
assert!(size.pitch > 0.0 && size.tap_drill > 0.0 && size.tap_drill < size.major_diameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapped_catalogue_converts_inches_and_uses_tabulated_cut_drills() {
|
||||
let coarse = parse_tapped_thread(" 1/4-20 unc ").unwrap();
|
||||
assert_eq!(coarse.designation, "1/4-20 UNC");
|
||||
assert_eq!(coarse.major_diameter, 6.35);
|
||||
assert_eq!(coarse.pitch, 1.27);
|
||||
assert_eq!(coarse.tap_drill, 5.1054);
|
||||
assert!((coarse.tap_drill - (coarse.major_diameter - coarse.pitch)).abs() > 0.02);
|
||||
let fine = parse_tapped_thread("#10-32 UNF").unwrap();
|
||||
assert_eq!(fine.major_diameter, 4.826);
|
||||
assert_eq!(fine.pitch, 25.4 / 32.0);
|
||||
assert_eq!(fine.tap_drill, 4.0386);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapped_catalogue_preserves_metric_custom_and_rejects_unverified_threads() {
|
||||
for (name, d, p, drill) in [("M8", 8.0, 1.25, 6.75), ("M8x1", 8.0, 1.0, 7.0), ("M8.5x1", 8.5, 1.0, 7.5)] {
|
||||
let size = parse_tapped_thread(name).unwrap();
|
||||
assert_eq!((size.major_diameter, size.pitch, size.tap_drill), (d, p, drill));
|
||||
}
|
||||
for invalid in ["1/4-20 UNF", "#10-32 UNC", "1/4-19 BSP", "Rc1/4", "R1/4", "1/4-18 NPT", "1/4-20", "M8xNaN", "M8x8"] {
|
||||
assert!(parse_tapped_thread(invalid).is_none(), "{invalid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: metric major minus pitch or tabulated UNC/UNF, G or Rp cut-tap drill) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" or \"1/4-20 UNC\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", pipe)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -1906,9 +1906,9 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let thread = str_(p, "thread").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
if let Some(t) = &thread
|
||||
&& solidcraft_doc::parse_metric_thread(t).is_none()
|
||||
&& solidcraft_doc::parse_tapped_thread(t).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M6 or M6x0.75)")));
|
||||
return Err(bad(cmd, format!("unknown tapped thread `{t}` (ISO metric or a catalogued UNC/UNF, G or Rp size)")));
|
||||
}
|
||||
// A position given as length expressions (#1678), say `["leg_h*0.4", "width/2", "t"]`: kept,
|
||||
// so the hole follows the parameters; drilled where they evaluate now.
|
||||
@@ -1976,7 +1976,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
centre
|
||||
};
|
||||
// The diameter: given, or the tap drill of the thread (major − pitch), or an ISO 273
|
||||
// The diameter: given, or a metric-estimate/Unified-table tap drill, or an ISO 273
|
||||
// clearance hole for a bolt size (close, normal or loose fit).
|
||||
let diameter = match (expr(p, "diameter"), &thread, str_(p, "clearance")) {
|
||||
(Some(d), _, _) => d,
|
||||
@@ -1986,8 +1986,8 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Expr::new(format!("{d} mm"))
|
||||
}
|
||||
(None, Some(t), None) => {
|
||||
let (major, pitch) = solidcraft_doc::parse_metric_thread(t).ok_or_else(|| bad(cmd, "thread"))?;
|
||||
Expr::new(format!("{} mm", ((major - pitch) * 1000.0).round() / 1000.0))
|
||||
let size = solidcraft_doc::parse_tapped_thread(t).ok_or_else(|| bad(cmd, "thread"))?;
|
||||
Expr::new(format!("{} mm", size.tap_drill))
|
||||
}
|
||||
(None, None, None) => return Err(bad(cmd, "needs `diameter`, `thread` (a tapped hole) or `clearance` (a bolt size)")),
|
||||
};
|
||||
|
||||
@@ -4223,3 +4223,23 @@ fn creating_a_component_recomputes_nothing() {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_pipe_large_nominals_support_shallow_and_thin_cylindrical_holes() {
|
||||
for designation in ["G 1", "Rp 1"] {
|
||||
for (height, depth) in [(15.0, Some(3.0)), (5.0, None)] {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("solid.box", &format!(r#"{{"length":80,"width":80,"height":{height},"body_name":"Plate"}}"#).parse().unwrap()).unwrap();
|
||||
let depth_json = depth.map(|d| format!(r#", "depth":{d}"#)).unwrap_or_default();
|
||||
let result =
|
||||
s.execute("solid.hole", &format!(r#"{{"position":[40,40,{height}],"thread":"{designation}"{depth_json}}}"#).parse().unwrap());
|
||||
assert!(result.is_ok(), "{designation}, height={height}, depth={depth:?}: {result:?}");
|
||||
let reopened = crate::Session::new(crate::doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.iter().all(|r| r.error.is_none()));
|
||||
let state = reopened.world_state();
|
||||
assert_eq!(state.threads.len(), 1);
|
||||
assert_eq!(state.threads[0].designation, designation);
|
||||
assert!((state.threads[0].end - state.threads[0].start - depth.unwrap_or(height)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3858,7 +3858,8 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
if let Some(error) = opts.custom_thread.as_ref().and_then(|draft| draft.error.as_ref()) {
|
||||
return Err(error.clone());
|
||||
}
|
||||
solidcraft_engine::doc::parse_metric_thread(&opts.thread).ok_or("select a valid metric thread size")?;
|
||||
solidcraft_engine::doc::parse_tapped_thread(&opts.thread)
|
||||
.ok_or("select a valid metric or catalogued UNC/UNF, G or Rp thread size")?;
|
||||
params["thread"] = json!(opts.thread.trim());
|
||||
}
|
||||
match *kind {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Metric thread choices use the document's sizing table and the existing designation
|
||||
//! parameter. Browsing choices does not edit the model or freeze an automatic feature.
|
||||
//! Tapped-hole catalogues and metric Thread choices share document sizing data.
|
||||
//! Browsing choices does not edit the model or freeze an automatic Thread feature.
|
||||
|
||||
use solidcraft_engine::doc::{Document, metric_thread_sizes, parse_metric_thread};
|
||||
use solidcraft_engine::doc::{Document, TappedThreadFamily, metric_thread_sizes, parse_metric_thread, parse_tapped_thread, tapped_thread_sizes};
|
||||
|
||||
use crate::dialogs::{combo, form_row};
|
||||
use crate::i18n::tr;
|
||||
@@ -56,37 +56,70 @@ impl HoleThreadDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tapped holes use the existing metric catalogue; the engine derives the tap drill.
|
||||
/// Tapped holes use a shared, finite catalogue; the engine derives the physical drill.
|
||||
/// Custom remains a dialog mode even when its current values match a catalogue size.
|
||||
pub(crate) fn hole_rows(ui: &mut egui::Ui, doc: &Document, designation: &mut String, custom: &mut Option<HoleThreadDraft>) -> bool {
|
||||
let designation_id = ui.make_persistent_id("hole_thread_designation");
|
||||
form_row(ui, "Profile", |ui| {
|
||||
ui.label(tr("thread.iso_metric"));
|
||||
});
|
||||
|
||||
let sizes = metric_thread_sizes();
|
||||
let current = parse_metric_thread(designation);
|
||||
let catalogue = current.and_then(|pair| sizes.iter().position(|s| *s == pair));
|
||||
if custom.as_ref().is_some_and(|draft| draft.source != *designation) || (custom.is_none() && catalogue.is_none()) {
|
||||
let families = [TappedThreadFamily::Metric, TappedThreadFamily::Unc, TappedThreadFamily::Unf, TappedThreadFamily::G, TappedThreadFamily::Rp];
|
||||
let family = parse_tapped_thread(designation).map(|s| s.family).unwrap_or(TappedThreadFamily::Metric);
|
||||
let mut profile = families.iter().position(|f| *f == family).unwrap_or(0);
|
||||
let before_profile = profile;
|
||||
form_row(ui, "Profile", |ui| combo(ui, "hole_thread_profile", &["ISO metric", "UNC", "UNF", "G / BSPP", "Rp"], &mut profile));
|
||||
let family = families.get(profile).copied().unwrap_or(TappedThreadFamily::Metric);
|
||||
let sizes = tapped_thread_sizes(family);
|
||||
if family != TappedThreadFamily::Metric {
|
||||
*custom = None;
|
||||
}
|
||||
if profile != before_profile
|
||||
&& let Some(size) = sizes.first()
|
||||
{
|
||||
*designation = size.designation.clone();
|
||||
*custom = None;
|
||||
}
|
||||
let current = parse_tapped_thread(designation);
|
||||
let catalogue = current.as_ref().and_then(|s| sizes.iter().position(|p| p.designation == s.designation));
|
||||
if family == TappedThreadFamily::Metric
|
||||
&& (custom.as_ref().is_some_and(|draft| draft.source != *designation) || (custom.is_none() && catalogue.is_none()))
|
||||
{
|
||||
*custom = Some(HoleThreadDraft::new(designation));
|
||||
}
|
||||
form_row(ui, "Size", |ui| {
|
||||
let mut selected = if custom.is_some() { sizes.len() } else { catalogue.unwrap_or(sizes.len()) };
|
||||
let before = selected;
|
||||
let mut labels: Vec<String> = sizes.iter().map(|(d, p)| self::designation(*d, *p)).collect();
|
||||
labels.push("Custom".to_string());
|
||||
let mut labels: Vec<String> = sizes.iter().map(|s| s.designation.clone()).collect();
|
||||
if family == TappedThreadFamily::Metric {
|
||||
labels.push("Custom".to_string());
|
||||
}
|
||||
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
combo(ui, "hole_thread_catalogue", &labels, &mut selected);
|
||||
let catalogue_id = match family {
|
||||
TappedThreadFamily::Metric => "hole_thread_catalogue_metric",
|
||||
TappedThreadFamily::Unc => "hole_thread_catalogue_unc",
|
||||
TappedThreadFamily::Unf => "hole_thread_catalogue_unf",
|
||||
TappedThreadFamily::G => "hole_thread_catalogue_g",
|
||||
TappedThreadFamily::Rp => "hole_thread_catalogue_rp",
|
||||
};
|
||||
combo(ui, catalogue_id, &labels, &mut selected);
|
||||
if selected != before {
|
||||
if let Some((d, p)) = sizes.get(selected) {
|
||||
*designation = self::designation(*d, *p);
|
||||
if let Some(size) = sizes.get(selected) {
|
||||
*designation = size.designation.clone();
|
||||
*custom = None;
|
||||
} else {
|
||||
*custom = Some(HoleThreadDraft::new(designation));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if matches!(family, TappedThreadFamily::G | TappedThreadFamily::Rp) {
|
||||
form_row(ui, "Thread diameter", |ui| {
|
||||
if let Some(size) = parse_tapped_thread(designation) {
|
||||
ui.label(format!("{:.3} mm, 55°", size.major_diameter));
|
||||
}
|
||||
});
|
||||
form_row(ui, "Pitch", |ui| {
|
||||
if let Some(size) = parse_tapped_thread(designation) {
|
||||
ui.label(format!("{:.4} mm ({} TPI)", size.pitch, (25.4 / size.pitch).round()));
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut enter = false;
|
||||
if let Some(draft) = custom {
|
||||
let (diameter_enter, diameter_changed) = text_row(ui, "Nominal diameter", &mut draft.diameter, "");
|
||||
@@ -96,15 +129,14 @@ pub(crate) fn hole_rows(ui: &mut egui::Ui, doc: &Document, designation: &mut Str
|
||||
}
|
||||
enter |= diameter_enter || pitch_enter;
|
||||
}
|
||||
|
||||
let response = form_row(ui, "Designation", |ui| crate::ui_kit::cell_editor(ui, designation_id, designation, "M5 or M5x0.5", false));
|
||||
let response = form_row(ui, "Designation", |ui| crate::ui_kit::cell_editor(ui, designation_id, designation, "M5, M5x0.5, 1/4-20 UNC", false));
|
||||
if response.changed() && custom.is_some() {
|
||||
*custom = Some(HoleThreadDraft::new(designation));
|
||||
}
|
||||
form_row(ui, "Tap drill diameter", |ui| {
|
||||
match parse_metric_thread(designation).filter(|_| custom.as_ref().is_none_or(|draft| draft.error.is_none())) {
|
||||
Some((d, p)) => {
|
||||
ui.label(format!("{:.3} mm", d - p));
|
||||
match parse_tapped_thread(designation).filter(|_| custom.as_ref().is_none_or(|draft| draft.error.is_none())) {
|
||||
Some(size) => {
|
||||
ui.label(format!("{} mm", size.tap_drill));
|
||||
}
|
||||
None => {
|
||||
ui.label(tr("thread.select_size"));
|
||||
@@ -382,7 +414,7 @@ mod contribution_tests {
|
||||
assert_eq!(parse_metric_thread(&format!("M{d}x{p}")), Some((*d, *p)));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
for original in ["M5", "M5x0.5", "M33x1", "invalid"] {
|
||||
for original in ["M5", "M5x0.5", "M33x1", "1/4-20 UNC", "#10-32 UNF", "invalid"] {
|
||||
let mut designation = original.to_string();
|
||||
let mut custom = None;
|
||||
let doc = Document::default();
|
||||
@@ -395,6 +427,121 @@ mod contribution_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_catalogue_profiles_select_unified_sizes_and_keep_metric_custom_mode() {
|
||||
let mut h = custom_hole_widget();
|
||||
h.step(&json!({"click":{"handle":"dialog:Profile"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Profile:UNC"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Size"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Size:1/4-20 UNC"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["thread"], "1/4-20 UNC");
|
||||
assert!(crate::scenario::handle_at("dialog:Nominal diameter").is_none());
|
||||
assert!(h.widget("5.1054 mm").is_some());
|
||||
h.step(&json!({"click":{"handle":"dialog:Profile"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Profile:UNF"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Size"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Size:#10-32 UNF"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["thread"], "#10-32 UNF");
|
||||
h.step(&json!({"click":{"handle":"dialog:Profile"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Profile:ISO metric"}})).unwrap();
|
||||
if let Some(Dialog { kind: Kind::Hole { opts, .. }, .. }) = h.app.dialog.as_mut() {
|
||||
opts.thread = "M8x1".into();
|
||||
}
|
||||
h.frames(3);
|
||||
assert!(h.widget("M8x1").is_some());
|
||||
assert!(crate::scenario::handle_at("dialog:Nominal diameter").is_none());
|
||||
// Choosing Custom is persistent even at a now-catalogued fine pitch.
|
||||
if let Some(Dialog { kind: Kind::Hole { opts, .. }, .. }) = h.app.dialog.as_mut() {
|
||||
opts.custom_thread = Some(HoleThreadDraft::new(&opts.thread));
|
||||
}
|
||||
h.frames(3);
|
||||
assert!(h.widget("Custom").is_some());
|
||||
assert!(crate::scenario::handle_at("dialog:Nominal diameter").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_catalogue_drills_real_cylinders_and_reopens_without_resizing() {
|
||||
for (name, major, pitch, drill) in [
|
||||
("1/4-20 UNC", 6.35, 1.27, 5.1054),
|
||||
("#10-32 UNF", 4.826, 25.4 / 32.0, 4.0386),
|
||||
("M8", 8.0, 1.25, 6.75),
|
||||
("M8x1", 8.0, 1.0, 7.0),
|
||||
("M8.5x1", 8.5, 1.0, 7.5),
|
||||
] {
|
||||
let (mut app, _) = hole();
|
||||
let out = app.run("solid.hole", json!({"position":[10,10,10], "thread":name})).unwrap();
|
||||
let id = out["feature"].as_u64().unwrap();
|
||||
let saved = app.session.doc.to_json();
|
||||
let reopened = Session::new(Document::from_json(&saved).unwrap());
|
||||
let world = reopened.world_state();
|
||||
let t = &world.threads[0];
|
||||
assert_eq!(t.designation, name);
|
||||
assert!((t.major_diameter - major).abs() < 1e-9 && (t.pitch - pitch).abs() < 1e-9);
|
||||
let body = &world.bodies[0].body;
|
||||
assert!(body.validity().is_empty());
|
||||
let face = solidcraft_engine::kernel::cylinder_face_at(body, Vec3::new(10.0 + drill / 2.0, 10.0, 5.0)).unwrap();
|
||||
assert!(face.internal && (2.0 * face.radius - drill).abs() < 1e-8);
|
||||
let actual = body.tessellate(0.001).unwrap().measure().volume;
|
||||
let expected = 4000.0 - std::f64::consts::PI * (drill / 2.0).powi(2) * 10.0;
|
||||
assert!((actual - expected).abs() < 0.5, "{name}: {actual} versus {expected}");
|
||||
// A saved explicit drill remains authoritative, including a legacy metric bore.
|
||||
let mut doc = Document::from_json(&saved).unwrap();
|
||||
let solidcraft_engine::doc::FeatureKind::Hole { diameter, .. } = &mut doc.feature_mut(id).unwrap().kind else { panic!("not Hole") };
|
||||
*diameter = format!("{} mm", drill + 0.05).as_str().into();
|
||||
let reopened = Session::new(Document::from_json(&doc.to_json()).unwrap());
|
||||
let world = reopened.world_state();
|
||||
let face = solidcraft_engine::kernel::cylinder_face_at(&world.bodies[0].body, Vec3::new(10.0 + (drill + 0.05) / 2.0, 10.0, 5.0)).unwrap();
|
||||
assert!((2.0 * face.radius - drill - 0.05).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_catalogue_rejects_tapered_holes_and_nonmetric_thread_features() {
|
||||
let (mut app, _) = hole();
|
||||
let before = app.session.doc.to_json();
|
||||
for name in ["1/4-18 NPT", "Rc1/4", "R1/4", "1/4-20 UNF"] {
|
||||
assert!(app.run("solid.hole", json!({"position":[10,10,10],"thread":name})).is_err());
|
||||
assert_eq!(app.session.doc.to_json(), before);
|
||||
}
|
||||
app.run("solid.hole", json!({"position":[10,10,10],"thread":"M8"})).unwrap();
|
||||
let before = app.session.doc.to_json();
|
||||
for designation in ["1/4-20 UNC", "G 1/4", "Rp 1/4"] {
|
||||
for modeled in [false, true] {
|
||||
assert!(app.run("solid.thread", json!({"face":[13.375,10,5],"designation":designation,"modeled":modeled})).is_err());
|
||||
assert_eq!(app.session.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
assert_eq!(app.session.doc.to_json(), before);
|
||||
let out = app.run("solid.thread", json!({"face":[13.375,10,5],"designation":"M8"})).unwrap();
|
||||
let solidcraft_engine::doc::FeatureKind::Thread { designation, modeled, .. } =
|
||||
&mut app.session.doc_mut().feature_mut(out["feature"].as_u64().unwrap()).unwrap().kind
|
||||
else {
|
||||
panic!("not Thread")
|
||||
};
|
||||
*designation = Some("1/4-20 UNC".into());
|
||||
*modeled = true;
|
||||
let reopened = Session::new(Document::from_json(&app.session.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.last().unwrap().error.as_ref().unwrap().contains("ISO metric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_catalogue_public_plate_evidence_uses_only_headless_model_rendering() {
|
||||
let Some(dir) = std::env::var_os("SOLIDCRAFT_CATALOGUE_EVIDENCE") else { return };
|
||||
crate::gpu::install_cpu_picture_renderer();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":50,"width":25,"height":6,"body_name":"ThreadPlate"})).unwrap();
|
||||
for (x, name) in [(10, "M8x1"), (25, "1/4-20 UNC"), (40, "#10-32 UNF")] {
|
||||
h.app.run("solid.hole", json!({"position":[x,12.5,6],"thread":name})).unwrap();
|
||||
}
|
||||
h.call("ui.view", json!({"view":"iso"}));
|
||||
let path = std::path::Path::new(&dir).join("catalogue-plate.png");
|
||||
let out = h.call("ui.render", json!({"path":path,"width":1000,"height":700,"grid":false,"axes":false}));
|
||||
assert_eq!(out["ok"], true, "{out}");
|
||||
std::fs::write(std::path::Path::new(&dir).join("catalogue-plate.solidcraft"), h.app.session.doc.to_json()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contribution_metric_designation_omits_only_the_catalogue_coarse_pitch() {
|
||||
for &(d, p) in metric_thread_sizes() {
|
||||
@@ -458,4 +605,71 @@ mod contribution_tests {
|
||||
assert!((state.threads[0].pitch - pitch).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parallel_pipe_profiles_switch_and_cancel_without_changing_the_document() {
|
||||
let mut h = custom_hole_widget();
|
||||
let before = h.app.session.doc.to_json();
|
||||
for (profile, name, drill) in [("G / BSPP", "G 1/4", "11.75 mm"), ("Rp", "Rp 1/4", "11.4 mm")] {
|
||||
h.step(&json!({"click":{"handle":"dialog:Profile"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("dialog:Profile:{profile}")}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Size"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("dialog:Size:{name}")}})).unwrap();
|
||||
h.frames(3);
|
||||
let command = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(command[0].1["thread"], name);
|
||||
assert!(command[0].1.get("diameter").is_none());
|
||||
assert!(h.widget(drill).is_some());
|
||||
assert!(crate::scenario::handle_at("dialog:Nominal diameter").is_none());
|
||||
}
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_pipe_holes_have_the_tabulated_cylinders_depth_and_saved_designations() {
|
||||
use solidcraft_engine::doc::{TappedThreadFamily, tapped_thread_sizes};
|
||||
for family in [TappedThreadFamily::G, TappedThreadFamily::Rp] {
|
||||
for size in tapped_thread_sizes(family) {
|
||||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||||
app.run("solid.box", json!({"length":80,"width":80,"height":15,"body_name":"Plate"})).unwrap();
|
||||
let result = app.run("solid.hole", json!({"position":[40,40,15],"thread":size.designation,"depth":10})).unwrap();
|
||||
let saved = app.session.doc.to_json();
|
||||
let reopened = Session::new(Document::from_json(&saved).unwrap());
|
||||
let state = reopened.world_state();
|
||||
assert_eq!(state.threads.len(), 1);
|
||||
let t = &state.threads[0];
|
||||
assert_eq!(t.designation, size.designation);
|
||||
assert!((t.major_diameter - size.major_diameter).abs() < 1e-9 && (t.pitch - size.pitch).abs() < 1e-9);
|
||||
assert!((t.end - t.start - 10.0).abs() < 1e-6);
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.validity().is_empty());
|
||||
for z in [6.0, 10.0, 14.0] {
|
||||
let c = solidcraft_engine::kernel::cylinder_face_at(body, Vec3::new(40.0 + size.tap_drill / 2.0, 40.0, z)).unwrap();
|
||||
assert!(c.internal && (2.0 * c.radius - size.tap_drill).abs() < 1e-8);
|
||||
}
|
||||
let actual = body.tessellate(0.001).unwrap().measure().volume;
|
||||
let removed = std::f64::consts::PI * (size.tap_drill / 2.0).powi(2) * 10.0;
|
||||
assert!((actual - (96000.0 - removed)).abs() / removed < 0.002, "{}: {actual}", size.designation);
|
||||
assert_eq!(reopened.doc.to_json(), saved);
|
||||
if size.designation.ends_with(" 1/4") {
|
||||
let mut doc = Document::from_json(&saved).unwrap();
|
||||
let solidcraft_engine::doc::FeatureKind::Hole { diameter, .. } =
|
||||
&mut doc.feature_mut(result["feature"].as_u64().unwrap()).unwrap().kind
|
||||
else {
|
||||
panic!("not Hole")
|
||||
};
|
||||
*diameter = format!("{} mm", size.tap_drill + 0.03).as_str().into();
|
||||
let reopened = Session::new(Document::from_json(&doc.to_json()).unwrap());
|
||||
let state = reopened.world_state();
|
||||
let c = solidcraft_engine::kernel::cylinder_face_at(
|
||||
&state.bodies[0].body,
|
||||
Vec3::new(40.0 + (size.tap_drill + 0.03) / 2.0, 40.0, 10.0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!((2.0 * c.radius - size.tap_drill - 0.03).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Tapped-hole catalogue
|
||||
|
||||
The Hole dialog offers ISO metric coarse sizes, 15 additional fine diameter/pitch
|
||||
pairs, a finite set of 14 UNC and 14 UNF sizes, and six sizes each for internal
|
||||
G/BSPP and Rp parallel pipe threads. This is a verified subset,
|
||||
not complete coverage of these standards. Unified sizes range from #4 through
|
||||
#12 and 1/4 through 1 inch. Include the family in a designation, for example
|
||||
`1/4-20 UNC` or `#10-32 UNF`.
|
||||
|
||||
Internal units are millimetres. Unified screw diameters convert using exactly
|
||||
25.4 mm/in; pitch is 25.4 divided by threads per inch. Numbered screw sizes are
|
||||
nominal screw diameters, not numbered drill diameters. Pipe fractions are nominal
|
||||
labels: G 1/4 has a basic major diameter of 13.157 mm, not 6.35 mm.
|
||||
|
||||
For UNC/UNF, the proposed pilot diameter comes from Guhring's **CUT TAPS,
|
||||
approximately 75% theoretical thread** column. Fractional, letter and numbered
|
||||
drills are converted to millimetres without rounding away the recommendation.
|
||||
These are starting recommendations for cutting taps, not form taps. Material,
|
||||
depth, tolerances, drilling accuracy and the tap manufacturer's process guidance
|
||||
still determine the appropriate drill; the command's explicit `diameter` can
|
||||
override the recommendation.
|
||||
|
||||
Metric holes retain the existing major-minus-pitch estimate, rounded to 0.001 mm.
|
||||
This preserves existing command behavior, including M8's 6.75 mm default. It is
|
||||
not presented as a tabulated 75% cut-tap recommendation. Existing files retain
|
||||
their saved diameter expressions. Custom metric dimensions remain editable, and
|
||||
Custom stays selected even when its current dimensions match a catalogue entry.
|
||||
|
||||
Tapped holes create the physical pilot bore and cosmetic thread metadata. The
|
||||
separate Thread command and its automatic sizing remain ISO metric only; this
|
||||
catalogue does not enable modelled Unified or pipe threads. Rc, external R, NPT,
|
||||
thread classes, handedness and sealing behavior are outside this tranche.
|
||||
No tapered thread is represented as a cylindrical hole.
|
||||
|
||||
## Parallel G/BSPP and Rp threads
|
||||
|
||||
Both families use a 55-degree profile and a cylindrical pilot bore. Keep G and Rp
|
||||
separate: their process-drill recommendations differ. The UI shows the nominal
|
||||
pipe designation, actual basic major diameter, pitch/TPI and pilot drill. The
|
||||
command accepts canonical labels such as `G 1/4` and `Rp 1/4`, compact forms
|
||||
`G1/4` and `Rp1/4`, and `BSPP 1/4` as an alias for G. The separate Thread command
|
||||
remains metric-only.
|
||||
|
||||
| Nominal | TPI | Major mm | Pitch diameter mm | Minor mm | G drill mm | Rp drill mm |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1/8 | 28 | 9.728 | 9.147 | 8.566 | 8.75 | 8.5 |
|
||||
| 1/4 | 19 | 13.157 | 12.301 | 11.445 | 11.75 | 11.4 |
|
||||
| 3/8 | 19 | 16.662 | 15.806 | 14.950 | 15.25 | 14.8 |
|
||||
| 1/2 | 14 | 20.955 | 19.793 | 18.631 | 19.0 | 18.5 |
|
||||
| 3/4 | 14 | 26.441 | 25.279 | 24.117 | 24.5 | 24.0 |
|
||||
| 1 | 11 | 33.249 | 31.770 | 30.291 | 30.75 | 30.2 |
|
||||
|
||||
Pitch is exactly 25.4/TPI. These basic thread dimensions are separate from process
|
||||
bores. The catalogue uses Yamawa's drill column as its default pilot. Its separate
|
||||
recommended process-bore references, for the six rows above, are G: 8.78, 11.78,
|
||||
15.28, 19.04, 24.52, 30.77 mm and Rp: 8.55, 11.5, 14.9, 18.55, 24.1, 30.25 mm.
|
||||
A saved explicit diameter remains authoritative. The geometry stays a physical
|
||||
cylindrical pilot plus cosmetic designation, without a helical cut.
|
||||
|
||||
## Primary sources
|
||||
|
||||
- [Yamawa, YEU23 European catalogue](https://www.yamawa.eu/files/Yamawa_European_Catalogue_YEU23.pdf),
|
||||
printed page 668: basic G/Rp dimensions; page 707: separate G and Rp drill and
|
||||
recommended bore columns. These supply the finite parallel-pipe entries above.
|
||||
|
||||
- [Guhring, Tap Drill Sizes](https://guhring.com/media/catalogs/lgbkp3ieki4.pdf),
|
||||
PDF page 2: UNC/UNF pitch and practical CUT TAPS approximately75% drills;
|
||||
page 1: decimal equivalents for named drills. The separate FORM TAPS columns
|
||||
are not used. Also verifies 14 of the additional metric fine pairs.
|
||||
- [PEM, Thread Code Comparison Chart](https://www.pemnet.com/resources/download-center/thread-code-comparison-chart/):
|
||||
nominal diameters of #4, #6, #8, #10 and #12 screws.
|
||||
- [Yamawa, European catalogue, printed page159](https://www.yamawa.eu/wp-content/uploads/Catalogo_sfogliabile_YEU21/files/basic-html/page161.html):
|
||||
M12x1.25 diameter/pitch pair. Its product-specific pilot recommendation is not
|
||||
used for the retained metric estimate.
|
||||
- [OSG, Tap Drill Sizes and Pitch Diameter Limits, Vol3 (2024)](https://osgtool.com/800274ca-v3/):
|
||||
independent cross-check of UNC/UNF pitches. OSG's calculated percentages and
|
||||
Guhring's practical named drills are not interchangeable columns.
|
||||
|
||||
## Trial
|
||||
|
||||
On a small plate, open Hole, choose Tapped, then switch Profile between ISO metric,
|
||||
UNC, UNF, G / BSPP and Rp. Try M8, M8x1, 1/4-20 UNC, #10-32 UNF, G 1/4
|
||||
and Rp 1/4. Check the displayed drill,
|
||||
create the hole, save, reopen and check the designation and bore. In ISO metric,
|
||||
choose Custom, edit the nominal diameter and pitch, and verify that Custom stays
|
||||
selected and an invalid draft cannot apply an earlier valid value.
|
||||
@@ -4,4 +4,4 @@ Generated size choices use the shared metric catalogue. Its coarse pitch is omit
|
||||
|
||||
This changes generated UI text, not the resolved numerical pitch or geometry. Existing authored designation text, including explicit legacy coarse pitches, is retained when a dialog is merely displayed. Custom and fine designations remain editable. Catalogue dimensions are millimetres regardless of the design's display units.
|
||||
|
||||
Tapped Hole continues to derive its existing tap-drill diameter from nominal diameter minus pitch: M8 gives 6.75 mm, while M8x1 gives 7 mm. Saving and reopening retains the resolved pitch. Only the currently supported ISO metric family is offered; other thread families and a complete fine-pitch catalogue are not added by this change.
|
||||
For ISO metric tapped holes, the existing tap-drill estimate remains nominal diameter minus pitch: M8 gives 6.75 mm, while M8x1 gives 7 mm. Saving and reopening retains the resolved pitch. See the [tapped-hole catalogue](thread-catalogue.md) for the additional metric fine choices and finite UNC/UNF and parallel G/Rp catalogues with their tabulated pilot recommendations. The separate Thread command remains ISO metric only.
|
||||
|
||||
Reference in New Issue
Block a user