Thread: the start never fades; ease_in is gone

A thread's start runs off its free end, plain or chamfered; only its inner end fades out
(ease_out) or stops square. The `ease_in` field, the command parameter (refused now, naming
ease_out), the dialog's Ease In row and their docs are removed; `pending/thread-ends.rs`
drops a saved `ease_in` as the design opens. The runout test covers the short, standard and a
sub-turn fade at the inner end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-27 04:08:03 -04:00
committed by Irakli Kourtis
parent 361b35afee
commit 5fe141c0f9
9 changed files with 86 additions and 87 deletions
+2 -3
View File
@@ -868,9 +868,8 @@ pub enum FeatureKind {
/// Starts (1 to 8): the lead is starts × pitch (unless `designation` gives the lead).
#[serde(default = "crate::thread_forms::one_start", skip_serializing_if = "crate::thread_forms::is_one_start")]
starts: u32,
/// Runout lengths at the thread's start and end (a groove that fades out).
#[serde(default, skip_serializing_if = "Option::is_none")]
ease_in: Option<ThreadRunout>,
/// The runout at the thread's inner end, the one away from where it starts (a groove
/// that fades out); none: the groove stops square there.
#[serde(default, skip_serializing_if = "Option::is_none")]
ease_out: Option<ThreadRunout>,
/// The chamfered tip at the thread's free end (ISO 4753).
+10 -12
View File
@@ -83,9 +83,9 @@ pub(super) struct ThreadAsk<'a> {
pub from: Option<Vec3>,
/// A modelled thread needs to know how each of its ends meets the face's ends.
pub modeled: bool,
/// Runouts at the end the thread starts from and at the other, each as (mm, pitches): its
/// length is mm + pitches x the pitch.
pub ease: [(f64, f64); 2],
/// The runout at the end away from where the thread starts, as (mm, pitches): its length
/// is mm + pitches x the pitch. The start never fades: it runs off its free end.
pub ease_out: (f64, f64),
/// A tapped hole's thread: `designation` is a catalogue size (ISO metric, or a catalogued
/// UNC/UNF, G or Rp size), and a size that is not metric fits the hole drilled for it.
pub tapped: bool,
@@ -302,10 +302,9 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
(a, b, side)
}
};
// The runout at each end of the axis: ease in where the thread starts from.
let len = |(mm, pitches): (f64, f64)| mm + pitches * pitch;
let (e_in, e_out) = (len(ask.ease[0]), len(ask.ease[1]));
let ease = if side == 0 { [e_in, e_out] } else { [e_out, e_in] };
// The runout at each end of the axis: only at the end away from where it starts.
let e_out = ask.ease_out.0 + ask.ease_out.1 * pitch;
let ease = if side == 0 { [0.0, e_out] } else { [e_out, 0.0] };
if ease.iter().any(|e| !(*e >= 0.0 && e.is_finite())) {
return Err(DocError::Invalid("a runout length must be positive".into()));
}
@@ -547,7 +546,7 @@ fn thread_grooves(
law.extend([(turns - r_out, 0.0), (turns, out)]);
}
if r_in > 0.0 && r_out > 0.0 && !(turns - r_out > r_in + 1e-9) || r_in.max(r_out) > turns {
return Err(DocError::Invalid(format!("the runouts ({} and {} mm) are longer than the thread", show_mm(ease[0]), show_mm(ease[1]))));
return Err(DocError::Invalid(format!("the runout ({} mm) is longer than the thread", show_mm(ease[0].max(ease[1])))));
}
if taper_rate != 0.0 {
law = with_taper(&law, turns, taper_rate);
@@ -670,7 +669,6 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
form,
hand,
starts,
ease_in,
ease_out,
tip,
chamfer_angle,
@@ -700,7 +698,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
extent,
from: from.map(|q| q.0),
modeled: *modeled,
ease: [(0.0, 0.0); 2],
ease_out: (0.0, 0.0),
tapped: false,
};
// ISO 3508's runouts: standard 2.5 P, short 1.25 P.
@@ -712,10 +710,10 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
Some(crate::ThreadRunout::Length { length }) => (val(vals, length, Kind::Length)?, 0.0),
})
};
if (ease_in.is_some() || ease_out.is_some()) && !*modeled {
if ease_out.is_some() && !*modeled {
return Err(DocError::Invalid("a runout is cut only on a modelled thread".into()));
}
let ask = ThreadAsk { ease: [runout(ease_in)?, runout(ease_out)?], ..ask };
let ask = ThreadAsk { ease_out: runout(ease_out)?, ..ask };
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side } = thread_on(st, f.id, face.0, &ask)?;
if *tip != ThreadTip::None && t.form.is_taper() {
return Err(DocError::Invalid("not supported yet: a chamfered tip on a taper pipe thread".into()));
+13 -9
View File
@@ -1,10 +1,11 @@
//! A modelled thread saves how its ends are finished: `ease_in` and `ease_out`, the runout at the
//! end it starts from and at the other (`standard` 2.5 P, `short` 1.25 P or `{length}`, ISO
//! 3508), and `tip`, which free ends are chamfered (`start`: the end it starts from, `both`: each
//! free end; ISO 4753), with `chamfer_angle` and `chamfer_depth`. Without them, as in an older
//! design, the groove stops square and no end is chamfered. Designs saved while `tip` read
//! `chamfer` (every free end) are rewritten as they open to `both`. The last release refuses a
//! design saved with this (the newer format number).
//! A modelled thread saves how its ends are finished: `ease_out`, the runout at the inner end,
//! away from where it starts (`standard` 2.5 P, `short` 1.25 P or `{length}`, ISO 3508), and
//! `tip`, which free ends are chamfered (`start`: the end it starts from, `both`: each free end;
//! ISO 4753), with `chamfer_angle` and `chamfer_depth`. Without them, as in an older design, the
//! groove stops square and no end is chamfered. Designs saved while these were being made are
//! rewritten as they open: `tip: chamfer` (every free end) becomes `both`, and an `ease_in` (a
//! runout at the start, which now always runs off its free end) is dropped. The last release
//! refuses a design saved with this (the newer format number).
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
@@ -19,6 +20,7 @@ pub fn step(v: &mut Value) -> crate::Result<()> {
if object.get("tip").and_then(Value::as_str) == Some("chamfer") {
object.insert("tip".into(), Value::String("both".into()));
}
object.remove("ease_in");
}
Ok(())
}
@@ -28,14 +30,16 @@ mod tests {
use serde_json::json;
#[test]
fn a_chamfered_tip_becomes_both_ends() {
fn a_chamfered_tip_becomes_both_ends_and_the_start_runout_goes() {
let mut v = json!({"features": [
{"type": "thread", "designation": "M10", "modeled": true, "tip": "chamfer"},
{"type": "thread", "designation": "M10", "modeled": true, "tip": "chamfer", "ease_in": "standard", "ease_out": "short"},
{"type": "thread", "designation": "M10", "modeled": true},
{"type": "chamfer", "tip": "chamfer"}
]});
super::step(&mut v).unwrap();
assert_eq!(v["features"][0]["tip"], json!("both"));
assert!(v["features"][0].get("ease_in").is_none());
assert_eq!(v["features"][0]["ease_out"], json!("short"));
assert!(v["features"][1].get("tip").is_none());
assert_eq!(v["features"][2]["tip"], json!("chamfer"), "only threads");
let mut empty = json!({"name": "Untitled"});
+8 -3
View File
@@ -93,7 +93,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its drill); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_in?, ease_out?: expr|standard|short (modelled threads: the groove fades out over this length, its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; ease_in at the end the thread starts from (`from`, or the start of its extent), ease_out at the other; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a shaft thicker than the thread is refused with the size to make it, never turned down"),
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its drill); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a shaft thicker than the thread is refused with the size to make it, never turned down"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
@@ -2977,7 +2977,13 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
}
})
};
let (ease_in, ease_out) = (ease("ease_in")?, ease("ease_out")?);
if p.get("ease_in").is_some() {
return Err(bad(
cmd,
"`ease_in` is gone: a thread's start runs off its free end, plain or chamfered (`tip: start`); fade the inner end with `ease_out`",
));
}
let ease_out = ease("ease_out")?;
let length = expr(p, "length");
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
@@ -3019,7 +3025,6 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
form,
hand,
starts,
ease_in,
ease_out,
tip,
chamfer_angle,
+26 -26
View File
@@ -985,39 +985,39 @@ fn m10_runout_mean(h: f64) -> f64 {
simpson / h
}
/// ISO 3508 runouts on a shaft between two collars: the groove fades out over 2.5 P (standard) at
/// the end it starts from and 1.25 P (short) at the other, its section moving out past the surface
/// as it turns, each reaching nothing where its section clears the collar. The cut is the
/// integral over the turn of the clipped section's 2π∫ r dA (Pappus with the section moving):
/// full turns at f(0), the runouts at the mean of f over the offsets they pass.
/// ISO 3508 runouts on a shaft between two collars: the thread starts at the lower collar and
/// its inner end fades out over 1.25 P (short) or 2.5 P (standard) before the upper one, its
/// section moving out past the surface as it turns, reaching nothing where its section clears the
/// collar. The cut is the integral over the turn of the clipped section's 2π∫ r dA (Pappus with
/// the section moving): full turns at f(0), the runout at the mean of f over the offsets it
/// passes. A fade shorter than a turn is cut too; the start never fades (`ease_in` is refused).
#[test]
fn a_runout_fades_the_groove_out_before_each_shoulder() {
let mut s = shaft_on_collar();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 35], "radius": 8, "height": 10, "operation": "join", "targets": ["Part"]}));
let before = exact_volume(&s, "Part");
run(
&mut s,
"solid.thread",
json!({"face": [5, 0, 20], "designation": "M10", "ease_in": "standard", "ease_out": "short", "from": [0, 0, 10], "modeled": true}),
);
let st = s.model.state();
assert!(st.body("Part").unwrap().body.validity().is_empty());
fn a_runout_fades_the_groove_out_before_the_shoulder() {
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
let root = 5.0 - 5.0 * p * 3f64.sqrt() / 16.0;
let h = 5.0 - root + 0.05 * p;
let turns = (35.0 - 10.0 - 2.0 * w) / p;
let (r_in, r_out) = (2.5, 1.25);
let want = 2.0 * PI * (m10_cut_per_radian(0.0) * (turns - r_in - r_out) + (r_in + r_out) * m10_runout_mean(h));
let got = before - exact_volume(&s, "Part");
// Where the section moves, the sweep's walls lie within its tolerance (1e-4 P) of the true
// surfaces, not on them: up to that times the runouts' walls (about 64 mm² a turn, 3.75 turns),
// 0.036 mm³. Full-depth turns are exact radially (6e-4 mm³ here).
let bound = 1e-4 * p * 64.0 * (r_in + r_out);
assert!((got - want).abs() < bound, "{got} vs {want}");
// Longer runouts than the thread are refused.
for (ease, r_out) in [(json!("short"), 1.25), (json!("standard"), 2.5), (json!("1 mm"), 1.0 / 1.5)] {
let mut s = shaft_on_collar();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 35], "radius": 8, "height": 10, "operation": "join", "targets": ["Part"]}));
let before = exact_volume(&s, "Part");
run(&mut s, "solid.thread", json!({"face": [5, 0, 20], "designation": "M10", "ease_out": ease, "from": [0, 0, 10], "modeled": true}));
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty(), "{ease}");
let want = 2.0 * PI * (m10_cut_per_radian(0.0) * (turns - r_out) + r_out * m10_runout_mean(h));
let got = before - exact_volume(&s, "Part");
// Where the section moves, the sweep's walls lie within its tolerance (1e-4 P) of the
// true surfaces, not on them: up to that times the walls (about 64 mm² a turn) of the
// runout and of a turn more. Measured: 0.018 (short), 0.012 (standard), 0.011 mm³ (1
// mm), not growing with the runout's length; full-depth turns are exact radially.
let bound = 1e-4 * p * 64.0 * (r_out + 1.0);
assert!((got - want).abs() < bound, "{ease}: {got} vs {want}");
}
// Longer runouts than the thread are refused, and so is a runout at the start.
let mut s = shaft_on_collar();
let e = s.execute("solid.thread", &json!({"face": [5, 0, 20], "designation": "M10", "ease_in": 14, "ease_out": 14, "modeled": true}));
let e = s.execute("solid.thread", &json!({"face": [5, 0, 20], "designation": "M10", "ease_out": 30, "modeled": true}));
assert!(e.err().map(|e| e.to_string()).unwrap_or_default().contains("longer than the thread"));
let e = s.execute("solid.thread", &json!({"face": [5, 0, 20], "designation": "M10", "ease_in": "standard", "modeled": true}));
assert!(e.err().map(|e| e.to_string()).unwrap_or_default().contains("`ease_in` is gone"));
}
/// A modelled M10 thread's flanks are several B-spline faces per turn (spans of one sweep); where
+2 -2
View File
@@ -234,7 +234,7 @@ fn control_role(command: &str, label: &str) -> Option<egui::accesskit::Role> {
| "Bend Line Position" | "From Snap" | "To Snap" | "Joint" | "Joint 1" | "Joint 2" | "Gear 1" | "Gear 2" | "Pinion" | "Rack" | "Screw"
| "Nut" | "Cam" | "Follower" | "Anchor" => Role::ComboBox,
"Radius" if command == "joint.rack_pinion" => Role::Label,
"Pitch" | "Form" | "Hand" | "Starts" | "Extent" | "Ease In" | "Ease Out" | "Tip" if command == "solid.thread" => Role::ComboBox,
"Pitch" | "Form" | "Hand" | "Starts" | "Extent" | "Ease Out" | "Tip" if command == "solid.thread" => Role::ComboBox,
"Automatic size" | "Reverse" | "Other side" | "All Lines" | "Mirror" => Role::CheckBox,
"Animate" => Role::Button,
"Ratio" if command.starts_with("joint.") && command != "joint.motion_link" => return None,
@@ -362,7 +362,7 @@ form_case!(path_pattern, "solid.pattern.path", ["Type", "Object Type", "Quantity
form_case!(pipe, "solid.pipe", ["Diameter", "Wall", "Operation"]);
form_case!(stock, "solid.bounding_solid", ["Margin"]);
form_case!(frame_member, "frame.member", ["All Lines", "Anchor", "Angle", "Mirror", "Name"]);
form_case!(thread, "solid.thread", ["Form", "Size", "Pitch", "Designation", "Hand", "Starts", "Extent", "Ease In", "Ease Out", "Tip"]);
form_case!(thread, "solid.thread", ["Form", "Size", "Pitch", "Designation", "Hand", "Starts", "Extent", "Ease Out", "Tip"]);
form_case!(primitive_box, "solid.box", ["length", "width", "height", "Operation"]);
form_case!(cylinder, "solid.cylinder", ["diameter", "height", "Operation"]);
form_case!(sphere, "solid.sphere", ["diameter", "Operation"]);
+6 -13
View File
@@ -221,7 +221,6 @@ pub struct Options {
pub starts: u32,
/// An index into the extents (full, length, up to).
pub extent: usize,
pub ease_in: Ease,
pub ease_out: Ease,
pub tip: ThreadTip,
pub chamfer_angle: String,
@@ -236,7 +235,6 @@ impl Default for Options {
hand: ThreadHand::default(),
starts: 1,
extent: FULL,
ease_in: Ease::default(),
ease_out: Ease::default(),
tip: ThreadTip::default(),
chamfer_angle: String::new(),
@@ -250,7 +248,7 @@ impl Options {
/// Does the thread start at one end of the face (a length or an Up To extent, a runout or a
/// tip)? Then the end to start from can be picked.
pub fn starts_at_an_end(&self) -> bool {
self.extent != FULL || self.ease_in.choice != 0 || self.ease_out.choice != 0 || self.tip != ThreadTip::None
self.extent != FULL || self.ease_out.choice != 0 || self.tip != ThreadTip::None
}
}
@@ -402,7 +400,6 @@ pub(crate) fn rows(
}
crate::ui_kit::section(ui, "thread_ends", "Ends", true, |ui| {
enter |= ease_rows(ui, "Ease In", "thread_ease_in", &mut o.ease_in);
enter |= ease_rows(ui, "Ease Out", "thread_ease_out", &mut o.ease_out);
pick(ui, "Tip", "thread_tip", &mut o.tip, TIPS);
if o.tip != ThreadTip::None {
@@ -424,7 +421,7 @@ fn ease_rows(ui: &mut egui::Ui, label: &'static str, id: &str, e: &mut Ease) ->
if before != EASE_LENGTH && e.length.trim().is_empty() {
e.length = "1 mm".into();
}
expr_row(ui, if label == "Ease In" { "Ease In Length" } else { "Ease Out Length" }, &mut e.length)
expr_row(ui, "Ease Out Length", &mut e.length)
}
/// The Size choice of a form with a size table, and the Pitch choice of the metric ones (an
@@ -525,12 +522,10 @@ pub(crate) fn put_params(p: &mut Value, length: &str, o: &Options, inputs: &[Sel
if let Some(from) = object_point(inputs, START_END) {
p["from"] = from;
}
for (key, e) in [("ease_in", &o.ease_in), ("ease_out", &o.ease_out)] {
match e.choice {
EASE_LENGTH => put(p, &[(key, e.length.as_str())]),
0 => {}
c => p[key] = json!(EASES[c].1),
}
match o.ease_out.choice {
EASE_LENGTH => put(p, &[("ease_out", o.ease_out.length.as_str())]),
0 => {}
c => p["ease_out"] = json!(EASES[c].1),
}
if o.tip != ThreadTip::None {
p["tip"] = json!(o.tip);
@@ -569,7 +564,6 @@ pub(crate) fn reopen(d: &mut Dialog, s: &Session, kind: &FeatureKind) {
form,
hand,
starts,
ease_in,
ease_out,
tip,
chamfer_angle,
@@ -593,7 +587,6 @@ pub(crate) fn reopen(d: &mut Dialog, s: &Session, kind: &FeatureKind) {
} else {
FULL
},
ease_in: Ease::of(ease_in),
ease_out: Ease::of(ease_out),
tip: *tip,
chamfer_angle: chamfer_angle.as_ref().map(expr).unwrap_or_default(),
+8 -9
View File
@@ -73,7 +73,6 @@ fn every_option_reaches_the_command() {
choose(&mut h, "Starts", "2");
choose(&mut h, "Extent", "Length");
type_in(&mut h, "Length", "6 mm");
choose(&mut h, "Ease In", "Standard (2.5 P)");
choose(&mut h, "Ease Out", "Length");
type_in(&mut h, "Ease Out Length", "1.5 mm");
choose(&mut h, "Tip", "Both");
@@ -86,14 +85,14 @@ fn every_option_reaches_the_command() {
p,
json!({
"designation": "Tr20x4", "form": "trapezoidal", "hand": "left", "starts": 2, "extent": "length", "length": "6 mm",
"ease_in": "standard", "ease_out": "1.5 mm", "tip": "both", "chamfer_angle": "30 deg", "chamfer_depth": "0.5 mm",
"ease_out": "1.5 mm", "tip": "both", "chamfer_angle": "30 deg", "chamfer_depth": "0.5 mm",
"modeled": true,
})
);
choose(&mut h, "Ease In", "Short (1.25 P)");
choose(&mut h, "Ease Out", "Short (1.25 P)");
choose(&mut h, "Hand", "Both");
let p = params(&h);
assert_eq!((&p["ease_in"], &p["hand"]), (&json!("short"), &json!("both")));
assert_eq!((&p["ease_out"], &p["hand"]), (&json!("short"), &json!("both")));
}
/// Buttress rows only for the buttress forms, the length only with a Length extent, the Up To
@@ -102,10 +101,10 @@ fn every_option_reaches_the_command() {
#[test]
fn only_the_fields_that_apply_are_shown() {
let mut h = shaft();
for row in ["Form", "Size", "Pitch", "Designation", "Hand", "Starts", "Extent", "Ease In", "Ease Out", "Tip"] {
for row in ["Form", "Size", "Pitch", "Designation", "Hand", "Starts", "Extent", "Ease Out", "Tip"] {
assert!(shown(row), "{row}");
}
for row in ["Steep Flank", "Length", "Ease In Length", "Ease Out Length", "Chamfer Angle", "Chamfer Depth"] {
for row in ["Steep Flank", "Length", "Ease Out Length", "Chamfer Angle", "Chamfer Depth"] {
assert!(!shown(row), "{row}");
}
let inputs = |h: &Harness| h.app.dialog.as_ref().unwrap().inputs.iter().map(|i| i.label).collect::<Vec<_>>();
@@ -130,9 +129,9 @@ fn only_the_fields_that_apply_are_shown() {
assert!(shown("Length"));
choose(&mut h, "Extent", "Full");
assert_eq!(inputs(&h), ["Cylindrical face"]);
choose(&mut h, "Ease In", "Length");
choose(&mut h, "Ease Out", "Length");
assert_eq!(inputs(&h), ["Cylindrical face", START_END], "a runout starts at an end");
assert!(shown("Ease In Length") && !shown("Ease Out Length"));
assert!(shown("Ease Out Length"));
choose(&mut h, "Tip", "Start");
assert!(shown("Chamfer Angle") && shown("Chamfer Depth"));
let p = params(&h);
@@ -246,7 +245,7 @@ fn a_saved_thread_reopens_with_every_option() {
for (made, picks) in [
(
json!({"designation":"S20x4","steep_flank":"start","hand":"left","starts":2,"length":"12 mm","from":[10,0,20],"modeled":true,
"ease_in":"standard","ease_out":"1 mm","tip":"start","chamfer_angle":"30 deg","chamfer_depth":"0.5 mm"}),
"ease_out":"1 mm","tip":"start","chamfer_angle":"30 deg","chamfer_depth":"0.5 mm"}),
vec!["Cylindrical face", START_END],
),
(json!({"designation":"M20","from":[10,0,20],"up_to":[10,0,5]}), vec!["Cylindrical face", START_END, UP_TO]),
+11 -10
View File
@@ -95,16 +95,17 @@ whole face.
## Runout and tip
**Runout** (`ease_in`, `ease_out`: a length, `standard` = 2.5 P or `short` = 1.25 P, ISO 3508's
normal and short thread runouts x): the groove fades out over that length, its section moving
out of the material as it turns (an external thread's away from the axis, past the surface; a
nut's towards it, into the hole), linearly, by SMLib's radial screw sweep. `ease_in` is at the end
the thread starts from (`from`, or the start of its extent), `ease_out` at the other. The runout
lies past the full-form length: beyond a square stop, and before a shoulder up to its clearance
(the full form then ends a runout short of it); at a free end it lies inside the face, the groove
reaching nothing at the end. No end gets a runout unless asked. Where the section moves, the
sweep's walls are within its tolerance (1e-4 P) of the true surfaces; full-depth turns are exact
radially.
**Runout** (`ease_out`: a length, `standard` = 2.5 P or `short` = 1.25 P, ISO 3508's normal and
short thread runouts x): the thread's inner end, away from where it starts, fades out over that
length, shorter than a turn too: its section moves out of the material as it turns (an external
thread's away from the axis, past the surface; a nut's towards it, into the hole), linearly, by
SMLib's radial screw sweep, and no end face is left. Without it the groove stops square there,
ending in its own profile, a flat face in the axial plane. The runout lies past the full-form
length: beyond a square stop, and before a shoulder up to its clearance (the full form then ends a
runout short of it); at a free end it lies inside the face, the groove reaching nothing at the
end. The start never fades: it runs off its free end, cut by the end face, plain or chamfered
(`tip`); `ease_in` is refused. Where the section moves, the sweep's walls are within its tolerance
(1e-4 P) of the true surfaces; full-depth turns are exact radially.
**Tip** (ISO 4753's chamfered end; `tip: start` the end the thread starts from, which must be
free, `tip: both` each free end it reaches): at those ends of a modelled external thread, after the groove, the material outside a cone `chamfer_angle` to the axis (45°) reaching