Hole presets: a modelled tapped thread and its End, and every catalogued size

A preset's thread options save `modeled` and `ease_out` (a named ISO 3508 runout or the fade's
length, kept portable as the other lengths are) and recall them into the dialog. A runout
without a modelled thread is no preset's. Recalling checked the thread with the metric parser
only, so a UNC, G, Rp, Rc or NPT preset was refused; it checks the tapped-thread catalogue now.
An older preset without these options recalls as before: listed, square.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMoxpM7Vuv8Rb6E3K2
This commit is contained in:
jyviko
2026-10-27 10:56:34 -04:00
committed by Irakli Kourtis
parent 72e0e00940
commit 376a91ad75
+81 -2
View File
@@ -39,16 +39,28 @@ struct ThreadOptions {
handedness: String,
full_length: bool,
depth: Option<String>,
/// The thread cut into the wall, not only listed.
#[serde(skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
/// A modelled thread's inner end: none is square; `standard` or `short` is ISO 3508's
/// runout, any other text a fade length.
#[serde(skip_serializing_if = "Option::is_none")]
ease_out: Option<String>,
#[serde(flatten)]
unknown: BTreeMap<String, serde_json::Value>,
}
impl Default for ThreadOptions {
fn default() -> Self {
Self { handedness: "right".into(), full_length: true, depth: None, unknown: BTreeMap::new() }
Self { handedness: "right".into(), full_length: true, depth: None, modeled: false, ease_out: None, unknown: BTreeMap::new() }
}
}
/// ISO 3508's runouts by name, not a typed length.
fn named_runout(text: &str) -> bool {
matches!(text, "standard" | "short")
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct Preset {
name: String,
@@ -108,6 +120,8 @@ impl Preset {
}
if required.into_iter().any(|text| text.trim().is_empty())
|| self.thread_options.depth.as_ref().is_some_and(|text| !valid_text(text, MAX_EXPRESSION))
|| self.thread_options.ease_out.as_ref().is_some_and(|text| text.trim().is_empty() || !valid_text(text, MAX_EXPRESSION))
|| (self.thread_options.ease_out.is_some() && !(self.tapped && self.thread_options.modeled))
|| !["right", "left"].contains(&self.thread_options.handedness.as_str())
|| self.thread_options.unknown.len() > 8
|| self.thread_options.unknown.iter().any(|(key, value)| {
@@ -189,6 +203,13 @@ impl Preset {
handedness: if opts.hand == solidcraft_engine::doc::thread_forms::ThreadHand::Left { "left".into() } else { "right".into() },
full_length: opts.thread_full_depth,
depth: if opts.thread_full_depth { None } else { Some(portable_length(&opts.thread_depth, true)?) },
modeled: opts.modeled,
// The runout as the command takes it: a named one, or the fade's length.
ease_out: match opts.ease_out.param() {
Some(serde_json::Value::String(text)) if opts.modeled && named_runout(&text) => Some(text),
Some(_) if opts.modeled => Some(portable_length(&opts.ease_out.length, true)?),
_ => None,
},
unknown: BTreeMap::new(),
}
} else {
@@ -231,7 +252,8 @@ impl Preset {
|| (!self.tapped && !length(&self.diameter))
|| (self.extent == Extent::Distance && !length(&self.depth))
|| (self.extent != Extent::All && (self.angled_bottom || self.style == Style::Drilled) && !angle(&self.tip_angle))
|| (self.tapped && solidcraft_engine::doc::parse_metric_thread(&self.thread).is_none())
|| (self.tapped && solidcraft_engine::doc::parse_tapped_thread(&self.thread).is_none())
|| self.thread_options.ease_out.as_deref().is_some_and(|text| !named_runout(text) && !length(text))
|| (self.tapped && !self.thread_options.full_length && self.thread_options.depth.as_deref().is_none_or(|text| !length(text)))
{
return Err("hole_presets.invalid_parameters");
@@ -272,6 +294,12 @@ impl Preset {
solidcraft_engine::doc::thread_forms::ThreadHand::Right
};
opts.thread_depth = self.thread_options.depth.clone().unwrap_or_else(|| crate::dialogs::HoleOpts::default().thread_depth);
opts.modeled = self.thread_options.modeled;
opts.ease_out = match self.thread_options.ease_out.as_deref() {
None => crate::dialogs_thread::Ease::default(),
Some(text) if named_runout(text) => crate::dialogs_thread::Ease { fade: true, short: text == "short", ..Default::default() },
Some(text) => crate::dialogs_thread::Ease { fade: true, length: text.into(), short: false },
};
// Existing placement picks stay with this dialog. To Object stores its mode only:
// recalling it always asks for a fresh destination in the current design.
crate::dialogs::preset_to_object(d, self.extent == Extent::ToObject);
@@ -551,4 +579,55 @@ mod tests {
assert_eq!(*opts.refs, *references);
assert_eq!(dialog.inputs, inputs);
}
/// A tapped preset keeps the thread's form and options: an Rc and an NPT size, modelled,
/// with a typed fade length or ISO 3508's short runout, and a square end; saved, read back
/// and recalled into a fresh dialog, each comes back as it was captured.
#[test]
fn hole_presets_round_trip_modelled_taper_threads_and_their_ends() {
use crate::dialogs_thread::Ease;
use solidcraft_engine::doc::thread_forms::ThreadHand;
let harness = crate::scenario::Harness::new();
let doc = Document::default();
for (thread, modeled, ease, saved) in [
("Rc 1/2", true, Ease { fade: true, length: "1.5 mm".into(), short: false }, Some("1.5 mm")),
("1/2-14 NPT", true, Ease { fade: true, length: String::new(), short: true }, Some("short")),
("Rc 3/4", true, Ease::default(), None),
("G 1/4", false, Ease::default(), None),
] {
let mut dialog = Dialog::for_command(&harness.app, "solid.hole").unwrap();
let Kind::Hole { opts, .. } = &mut dialog.kind else { panic!("expected Hole") };
opts.tapped = true;
opts.thread = thread.into();
opts.hand = ThreadHand::Left;
opts.thread_full_depth = false;
opts.thread_depth = "8 mm".into();
opts.modeled = modeled;
opts.ease_out = ease.clone();
let captured = opts.clone();
let preset = Preset::capture(&dialog, "Pipe", &doc).unwrap();
let row = serde_json::to_value(&preset).unwrap();
assert_eq!(row["thread_options"].get("ease_out").and_then(|v| v.as_str()), saved, "{thread}: {row}");
assert_eq!(row["thread_options"].get("modeled").is_some(), modeled, "{thread}: {row}");
let read: Preset = serde_json::from_value(row).unwrap();
assert_eq!(read, preset);
read.validate_active(&doc).unwrap_or_else(|e| panic!("{thread}: {e}"));
let mut fresh = Dialog::for_command(&harness.app, "solid.hole").unwrap();
read.recall(&mut fresh).unwrap();
let Kind::Hole { opts, .. } = &fresh.kind else { panic!("expected Hole") };
assert_eq!(
(&opts.thread, opts.hand, opts.thread_full_depth, &opts.thread_depth, opts.modeled, &opts.ease_out),
(&captured.thread, captured.hand, false, &captured.thread_depth, modeled, &ease),
"{thread}"
);
}
// A runout without a modelled thread is no preset's.
let mut dialog = Dialog::for_command(&harness.app, "solid.hole").unwrap();
let Kind::Hole { opts, .. } = &mut dialog.kind else { panic!("expected Hole") };
opts.tapped = true;
opts.thread = "M8".into();
let mut preset = Preset::capture(&dialog, "Square", &doc).unwrap();
preset.thread_options.ease_out = Some("standard".into());
assert!(preset.validate().is_err());
}
}