From 1c12fa83b0ee37d7f2aed1c88658ea18b03a0c67 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 12:03:22 +0000 Subject: [PATCH] =?UTF-8?q?Holes:=20a=20tapped=20hole=20takes=20the=20tap?= =?UTF-8?q?=20drill=20of=20its=20thread=20(major=20=E2=88=92=20pitch)=20wh?= =?UTF-8?q?en=20no=20diameter=20is=20given,=20and=20clearance:=20"M6"=20(f?= =?UTF-8?q?it=20close,=20normal=20or=20loose)=20gives=20the=20ISO=20273=20?= =?UTF-8?q?clearance=20diameter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/features.rs | 54 +++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 18 +++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index f2a863ab..672cbfd4 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("hole") .key("H") - .params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), + .params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), CommandSpec::new("PrimitivePipe", "Pipe", pipe) .at("SOLID", "CREATE") .icon("pipe") @@ -719,6 +719,42 @@ fn split_body(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::Split { body, plane }) } +/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts. +fn clearance_diameter(size: &str, fit: &str) -> Option { + const TABLE: [(f64, f64, f64, f64); 21] = [ + (1.6, 1.7, 1.8, 2.0), + (2.0, 2.2, 2.4, 2.6), + (2.5, 2.7, 2.9, 3.1), + (3.0, 3.2, 3.4, 3.6), + (4.0, 4.3, 4.5, 4.8), + (5.0, 5.3, 5.5, 5.8), + (6.0, 6.4, 6.6, 7.0), + (8.0, 8.4, 9.0, 10.0), + (10.0, 10.5, 11.0, 12.0), + (12.0, 13.0, 13.5, 14.5), + (14.0, 15.0, 15.5, 16.5), + (16.0, 17.0, 17.5, 18.5), + (20.0, 21.0, 22.0, 24.0), + (24.0, 25.0, 26.0, 28.0), + (30.0, 31.0, 33.0, 35.0), + (36.0, 37.0, 39.0, 42.0), + (42.0, 43.0, 45.0, 48.0), + (48.0, 50.0, 52.0, 56.0), + (56.0, 58.0, 62.0, 66.0), + (60.0, 62.0, 66.0, 70.0), + (64.0, 66.0, 70.0, 74.0), + ]; + let t = size.trim().trim_start_matches(['M', 'm']); + let d: f64 = t.split(['x', 'X']).next()?.trim().parse().ok()?; + let row = TABLE.iter().find(|r| (r.0 - d).abs() < 1e-9)?; + match fit { + "close" | "fine" => Some(row.1), + "normal" | "medium" => Some(row.2), + "loose" | "coarse" => Some(row.3), + _ => None, + } +} + fn hole(s: &mut Session, p: &Value) -> Result { let cmd = "FusionHoleCommand"; // At sketch points: the holes go perpendicular to the sketch and follow its points. @@ -776,7 +812,21 @@ fn hole(s: &mut Session, p: &Value) -> Result { } }, }; - let diameter = req_expr(cmd, p, "diameter")?; + // The diameter: given, or the tap drill of the thread (major − pitch), 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, + (None, _, Some(c)) => { + let fit = str_(p, "fit").unwrap_or("normal").to_ascii_lowercase(); + let d = clearance_diameter(c, &fit).ok_or_else(|| bad(cmd, format!("no clearance hole for `{c}` ({fit} fit): M1.6 to M64")))?; + format!("{d} mm") + } + (None, Some(t), None) => { + let (major, pitch) = solvecraft_doc::parse_metric_thread(t).ok_or_else(|| bad(cmd, "thread"))?; + format!("{} mm", ((major - pitch) * 1000.0).round() / 1000.0) + } + (None, None, None) => return Err(bad(cmd, "needs `diameter`, `thread` (a tapped hole) or `clearance` (a bolt size)")), + }; check_expr(s, &diameter, Kind::Length, cmd, "diameter")?; let depth = expr(p, "depth"); if let Some(d) = &depth { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ea2f1a48..ef90ba23 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1341,3 +1341,21 @@ mod naming { assert_eq!(h, vec!["F1:+z".to_string()]); } } + +/// Tapped holes take the tap drill (major − pitch); clearance holes the ISO 273 diameter. +#[test] +fn tapped_and_clearance_holes() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10})); + let v0 = volume(&mut s); + run(&mut s, "FusionHoleCommand", json!({"position": [10, 15, 10], "thread": "M6"})); + let v1 = volume(&mut s); + assert!(rel(v0 - v1, PI * 2.5 * 2.5 * 10.0) < 1e-3, "{}", v0 - v1); + run(&mut s, "FusionHoleCommand", json!({"position": [30, 15, 10], "clearance": "M6"})); + let v2 = volume(&mut s); + assert!(rel(v1 - v2, PI * 3.3 * 3.3 * 10.0) < 1e-3, "{}", v1 - v2); + run(&mut s, "FusionHoleCommand", json!({"position": [20, 5, 10], "clearance": "M4", "fit": "close"})); + assert!(rel(v2 - volume(&mut s), PI * 2.15 * 2.15 * 10.0) < 1e-3); + assert!(s.execute("FusionHoleCommand", &json!({"position": [20, 25, 10], "clearance": "M7"})).is_err()); + assert!(s.execute("FusionHoleCommand", &json!({"position": [20, 25, 10]})).is_err()); +}