183 lines
8.8 KiB
Rust
183 lines
8.8 KiB
Rust
//! Modelled threads: the ISO metric profile cut along a helix, M3 to M20, outside and inside.
|
|
|
|
use std::f64::consts::PI;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
|
|
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
|
match s.execute(id, &p) {
|
|
Ok(v) => v,
|
|
Err(e) => panic!("{id} {p}: {e}"),
|
|
}
|
|
}
|
|
|
|
/// Volume from the body's mesh: exact for the faceted threaded body, and quicker than a full
|
|
/// measurement of its thousands of faces.
|
|
fn volume(s: &mut Session, b: &str) -> f64 {
|
|
let st = s.model.state();
|
|
st.body(b).map(|mb| mb.body.tessellate(1e-3).unwrap().measure().volume).unwrap_or(0.0)
|
|
}
|
|
|
|
const SIZES: [(f64, f64); 9] = [(3.0, 0.5), (4.0, 0.7), (5.0, 0.8), (6.0, 1.0), (8.0, 1.25), (10.0, 1.5), (12.0, 1.75), (16.0, 2.0), (20.0, 2.5)];
|
|
|
|
#[test]
|
|
fn non_finite_thread_designations_are_refused_without_changing_the_design() {
|
|
let mut s = Session::default();
|
|
run(&mut s, "solid.cylinder", json!({"radius":4,"height":10}));
|
|
let saved = s.doc.to_json();
|
|
for designation in ["Minfx1", "M8xinf", "MNaNx1", "M8xNaN", "M1e999x1"] {
|
|
let result = s.execute("solid.thread", &json!({"face":[4,0,2],"designation":designation}));
|
|
assert!(result.is_err(), "{designation}: {result:?}");
|
|
assert_eq!(s.doc.to_json(), saved);
|
|
}
|
|
}
|
|
|
|
/// ∫ 2π r · width(r) dr over [a, b]: the volume one turn of the groove takes out (a screw
|
|
/// motion of a section in an axial plane sweeps 2π∫r dA per turn).
|
|
fn per_turn(a: f64, b: f64, width: impl Fn(f64) -> f64) -> f64 {
|
|
let n = 400;
|
|
(0..n).map(|i| a + (b - a) * (i as f64 + 0.5) / n as f64).map(|r| 2.0 * PI * r * width(r) * (b - a) / n as f64).sum()
|
|
}
|
|
|
|
#[test]
|
|
fn modelled_external_threads_m3_to_m20() {
|
|
let h = |p: f64| p * 3f64.sqrt() / 2.0;
|
|
let slope = (PI / 6.0).tan();
|
|
for (d, p) in SIZES {
|
|
let mut s = Session::default();
|
|
let len = 4.0 * p;
|
|
run(&mut s, "solid.cylinder", json!({"radius": d / 2.0, "height": len + 2.0, "body_name": "Shaft"}));
|
|
let before = volume(&mut s, "Shaft");
|
|
let th = run(&mut s, "solid.thread", json!({"face": [d / 2.0, 0, 1], "length": len, "modeled": true}));
|
|
assert!(th["thread"].is_null(), "a modelled thread is real geometry, not an annotation: {th}");
|
|
let r_root = d / 2.0 - 5.0 * h(p) / 8.0;
|
|
let want = per_turn(r_root, d / 2.0, |r| p / 4.0 + 2.0 * (r - r_root) * slope) * len / p;
|
|
let got = before - volume(&mut s, "Shaft");
|
|
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn modelled_internal_threads_m3_to_m20() {
|
|
let h = |p: f64| p * 3f64.sqrt() / 2.0;
|
|
let slope = (PI / 6.0).tan();
|
|
for (d, p) in SIZES {
|
|
let mut s = Session::default();
|
|
let len = 4.0 * p;
|
|
let d1 = d - 2.0 * 5.0 * h(p) / 8.0;
|
|
run(&mut s, "solid.box", json!({"corner": [-d, -d, 0], "length": 2.0 * d, "width": 2.0 * d, "height": len, "body_name": "Nut"}));
|
|
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -1], "radius": d1 / 2.0, "height": len + 2.0, "body_name": "Bore"}));
|
|
run(&mut s, "solid.combine", json!({"target": "Nut", "tools": ["Bore"], "operation": "cut"}));
|
|
let before = volume(&mut s, "Nut");
|
|
let th = run(&mut s, "solid.thread", json!({"face": [d1 / 2.0, 0, len / 2.0], "modeled": true}));
|
|
assert!(th["thread"].is_null(), "{th}");
|
|
// Bolt-shaped space: P/8 flat at the major diameter, widening towards the bore.
|
|
let want = per_turn(d1 / 2.0, d / 2.0, |r| p / 8.0 + 2.0 * (d / 2.0 - r) * slope) * len / p;
|
|
let got = before - volume(&mut s, "Nut");
|
|
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
|
|
}
|
|
}
|
|
|
|
/// Issue 995: an M5 modelled thread in a 5 mm hole cut nothing (the groove lay in the hole, from
|
|
/// the minor diameter out to the 5 mm major one) and only left the wall faceted. A modelled thread
|
|
/// that would cut next to nothing is refused, with the drill to use and the size that fits;
|
|
/// a cosmetic one is still only a note.
|
|
#[test]
|
|
fn a_modelled_thread_that_would_cut_nothing_is_refused() {
|
|
let mut s = hex_nut(5.0);
|
|
let before = volume(&mut s, "Nut");
|
|
let e = s.execute("solid.thread", &json!({"face": [2.5, 0, 2], "designation": "M5", "modeled": true})).err().map(|e| e.to_string());
|
|
let e = e.unwrap_or_default();
|
|
assert!(e.contains("this 5 mm hole") && e.contains("drill 4.2 mm for M5") && e.contains("thread this hole M6"), "{e}");
|
|
assert!((volume(&mut s, "Nut") - before).abs() < 1e-9);
|
|
run(&mut s, "solid.thread", json!({"face": [2.5, 0, 2], "designation": "M5"}));
|
|
|
|
// A shaft no thicker than the thread's root (17.29 mm for M20) is refused the same way.
|
|
let mut s = Session::default();
|
|
run(&mut s, "solid.cylinder", json!({"radius": 8.6, "height": 10, "body_name": "Shaft"}));
|
|
let e = s.execute("solid.thread", &json!({"face": [8.6, 0, 5], "designation": "M20", "modeled": true})).err().map(|e| e.to_string());
|
|
let e = e.unwrap_or_default();
|
|
assert!(e.contains("this 17.2 mm shaft") && e.contains("make the shaft 20 mm for M20") && e.contains("thread this shaft M18"), "{e}");
|
|
}
|
|
|
|
/// The limit is a quarter of the basic depth 5H/8: a hole up to 4.78 mm for M5 (4.86 mm for
|
|
/// M5x0.5) still takes a modelled thread, a wider one is refused with that pitch's drill.
|
|
#[test]
|
|
fn the_cut_nothing_limit_holds_for_coarse_and_fine_pitch() {
|
|
for (m, hole, refused) in [
|
|
("M5", 4.5, None),
|
|
("M5", 4.75, None),
|
|
("M5", 4.8, Some("drill 4.2 mm for M5, or thread this hole M6")),
|
|
("M5x0.5", 4.5, None),
|
|
("M5x0.5", 4.85, None),
|
|
("M5x0.5", 4.9, Some("drill 4.5 mm for M5x0.5, or thread this hole M6")),
|
|
("M5x0.5", 5.0, Some("drill 4.5 mm for M5x0.5, or thread this hole M6")),
|
|
] {
|
|
let mut s = hex_nut(hole);
|
|
let before = volume(&mut s, "Nut");
|
|
let made = s.execute("solid.thread", &json!({"face": [hole / 2.0, 0, 2], "designation": m, "modeled": true}));
|
|
let cut = before - volume(&mut s, "Nut");
|
|
match (refused, made) {
|
|
(None, Ok(_)) => assert!(cut > 0.05, "{m} in {hole} mm: removed {cut}"),
|
|
(Some(want), Err(e)) => {
|
|
let e = e.to_string();
|
|
assert!(e.contains(&format!("this {hole} mm hole")) && e.contains(want), "{m} in {hole} mm: {e}");
|
|
assert!(cut.abs() < 1e-9, "{m} in {hole} mm: removed {cut}");
|
|
}
|
|
(want, got) => panic!("{m} in {hole} mm: want refused {want:?}, got {:?}", got.map(|_| ())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A 4 mm hex nut (inscribed radius 5) with a plain hole of this diameter through it.
|
|
fn hex_nut(hole: f64) -> Session {
|
|
let mut s = Session::default();
|
|
for (id, p) in [
|
|
("sketch.create", json!({"plane": "XY"})),
|
|
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 6})),
|
|
("sketch.finish", json!({})),
|
|
("solid.extrude", json!({"distance": 4, "body_name": "Nut"})),
|
|
("solid.hole", json!({"position": [0, 0, 4], "diameter": hole})),
|
|
] {
|
|
run(&mut s, id, p);
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
|
|
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
|
|
/// preview then compared each of them with the whole old body.
|
|
#[test]
|
|
fn fillet_beside_a_modelled_thread_finishes() {
|
|
let mut s = Session::default();
|
|
for (id, p) in [
|
|
("sketch.create", json!({"plane": "XY"})),
|
|
("sketch.circle.center", json!({"center": [0, 0], "diameter": 5})),
|
|
("sketch.finish", json!({})),
|
|
("solid.extrude", json!({"distance": 10, "body_name": "Screw"})),
|
|
("sketch.create", json!({"plane": {"face": [0, 0, 10]}})),
|
|
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 2, "sides": 6})),
|
|
("sketch.finish", json!({})),
|
|
("solid.extrude", json!({"distance": 3, "profiles": [{"point": [0, 0]}], "operation": "join", "targets": ["Screw"]})),
|
|
("solid.thread", json!({"face": [2.5, 0, 5], "designation": "M5", "length": 4, "modeled": true})),
|
|
] {
|
|
run(&mut s, id, p);
|
|
}
|
|
let faces_before = s.model.state().body("Screw").map(|b| b.body.face_count()).unwrap_or(0);
|
|
assert!(faces_before > 1000, "the thread is faceted: {faces_before} faces");
|
|
let volume_before = volume(&mut s, "Screw");
|
|
let top: Vec<Value> = (0..6)
|
|
.map(|i| {
|
|
let a = (30.0 + 60.0 * i as f64).to_radians();
|
|
json!([2.0 * a.cos(), 2.0 * a.sin(), 13])
|
|
})
|
|
.collect();
|
|
let started = std::time::Instant::now();
|
|
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 0.3}));
|
|
assert!(started.elapsed().as_secs() < 30, "the fillet took {:?}", started.elapsed());
|
|
let volume_after = volume(&mut s, "Screw");
|
|
assert!(volume_after < volume_before && volume_after > volume_before * 0.99, "{volume_before} -> {volume_after}");
|
|
}
|