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:
amolinari-autotex
2026-10-24 10:22:57 +00:00
committed by GitHub
parent 9a737bd63c
commit 19db763899
15 changed files with 863 additions and 45 deletions
+2
View File
@@ -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",
+2
View File
@@ -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": "終了側エッジ",
+2
View File
@@ -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
View File
@@ -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.
+3
View File
@@ -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;
+212
View File
@@ -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());
}
}
+207
View File
@@ -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}");
}
}
}
+6 -6
View File
@@ -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)")),
};
+20
View File
@@ -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);
}
}
}
+2 -1
View File
@@ -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 {
+238 -24
View File
@@ -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);
}
}
}
}
}
+86
View File
@@ -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.
+1 -1
View File
@@ -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.