Format 40: a thread's hand on tapped holes too; v0.24.0's handedness opens as hand

v0.24.0 (format 39) saved a left-hand Thread or tapped Hole as `handedness: "left"`; this
branch replaced main's handedness (#4414) with the thread's `hand`, and features don't refuse
unknown fields, so such a design opened right-handed without a word. The pending format change
thread-hand.rs renames `handedness` to `hand` on Hole and Thread features (a `right` is
dropped, anything else refused).

Tapped holes take `hand` again, right or left (never both, and only with `thread`): the Hole
feature saves it, its thread is cut and annotated that way, solid.hole takes `hand?`, the hole
dialog's tapped rows have the thread dialog's Hand choice (without Both), editing a hole keeps
it, and hole presets save it under v0.24.0's `thread_options.handedness` key, so presets saved
by v0.24.0 recall it.

Test: a format-39 design with a left-hand thread and tapped hole opens left-handed; a hole's
`hand` refuses both and needs a thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-27 02:13:09 -04:00
parent 826f36a794
commit 8aea8e5a82
9 changed files with 100 additions and 5 deletions
+3
View File
@@ -828,6 +828,9 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Thread depth")]
thread_depth: Option<Expr>,
/// The tapped thread's hand: right (the default) or left; never both.
#[serde(default, skip_serializing_if = "crate::thread_forms::is_default")]
hand: crate::thread_forms::ThreadHand,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<FacePick>,
+2 -2
View File
@@ -6279,7 +6279,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -6306,7 +6306,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
};
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, ..Default::default() };
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
let mut t = thread_on(st, f.id, wall, &ask)?.info;
if let Some(depth) = thread_depth {
let available = t.end - t.start;
@@ -0,0 +1,26 @@
//! A thread's direction is saved as `hand` on Hole features too (`right`, the default, not
//! saved, or `left`), as Thread features already save it (`right`, `left` or `both`). Format 39
//! (v0.24.0) saved the direction of both as `handedness: "left"`; this step renames it to
//! `hand`, so a left-hand thread or tapped hole opens left-handed, and drops a `handedness:
//! "right"`. Older designs have neither and need nothing. The last release refuses a design with
//! this change (the newer format number).
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
let Some(Value::Array(features)) = v.get_mut("features") else {
return Ok(());
};
for feature in features {
let threaded = matches!(feature.get("type").and_then(Value::as_str), Some("hole" | "thread"));
let Some(f) = feature.as_object_mut().filter(|_| threaded) else { continue };
match f.remove("handedness") {
None => {}
Some(Value::String(h)) if h == "right" => {}
Some(Value::String(h)) if h == "left" => {
f.insert("hand".into(), Value::from("left"));
}
Some(other) => return Err(crate::DocError::Invalid(format!("a thread's `handedness` must be right or left, not {other}"))),
}
}
Ok(())
}
+10 -1
View File
@@ -69,7 +69,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: 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); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); 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); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); 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")
@@ -2421,6 +2421,14 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
_ => None,
};
let thread = str_(p, "thread").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
let hand = match str_(p, "hand") {
None | Some("right") => solidcraft_doc::thread_forms::ThreadHand::Right,
Some("left") => solidcraft_doc::thread_forms::ThreadHand::Left,
Some(h) => return Err(bad(cmd, format!("unknown hand `{h}` (right or left)"))),
};
if thread.is_none() && hand != solidcraft_doc::thread_forms::ThreadHand::Right {
return Err(bad(cmd, "`hand` needs `thread` (a tapped hole)"));
}
if let Some(t) = &thread
&& solidcraft_doc::parse_tapped_thread(t).is_none()
{
@@ -2593,6 +2601,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
points: points.map(|x| x.0),
thread,
thread_depth,
hand,
to: to.map(FacePick),
references,
},
+36
View File
@@ -711,6 +711,42 @@ fn a_left_handed_thread_turns_the_other_way() {
assert!(volumes[2] > want * 1.2 && volumes[2] < 2.0 * want, "{volumes:?}");
}
/// A left-hand thread and tapped hole saved by v0.24.0 (format 39, `handedness: "left"`) open
/// left-handed; a tapped hole takes `hand` like a thread, never `both`, and only with a thread.
#[test]
fn a_format_39_left_hand_thread_and_tapped_hole_open_left_handed() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 12, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [20, 30, 12], "thread": "M6", "depth": 8, "hand": "left"}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 9, "base": [80, 0, 0], "body_name": "Shaft", "operation": "new"}));
run(&mut s, "solid.thread", json!({"face": [85, 0, 4.5], "designation": "M10", "hand": "left"}));
let hands = |s: &Session| -> Vec<solidcraft_doc::thread_forms::ThreadHand> {
s.model.state().threads.iter().map(|t| t.hand).collect()
};
let left = solidcraft_doc::thread_forms::ThreadHand::Left;
assert_eq!(hands(&s), [left, left]);
// What format 39 wrote: the direction as `handedness`, no pending changes.
let mut v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
v["format"] = json!("solidcraft/39");
v.as_object_mut().unwrap().remove("format_pending");
for f in v["features"].as_array_mut().unwrap() {
if let Some(h) = f.as_object_mut().unwrap().remove("hand") {
f["handedness"] = h;
}
}
let reopened = Session::new(solidcraft_doc::Document::from_json(&v.to_string()).unwrap());
assert_eq!(hands(&reopened), [left, left]);
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 12}));
for (params, why) in [
(json!({"position": [20, 30, 12], "thread": "M6", "depth": 8, "hand": "both"}), "unknown hand"),
(json!({"position": [20, 30, 12], "depth": 8, "hand": "left"}), "needs `thread`"),
] {
let err = s.execute("solid.hole", &params).unwrap_err().to_string();
assert!(err.contains(why), "{err}");
}
}
/// A trapezoidal nut: the groove from the drilled minor diameter D1 = d - P out to the
/// internal root D4 = d + 2 ac (ISO 2904), 2π∫ r w(r) dr per turn.
#[test]
+8 -1
View File
@@ -415,6 +415,8 @@ pub struct HoleOpts {
/// Use the full useful cylindrical wall, independently of the drill extents.
pub thread_full_depth: bool,
pub thread_depth: String,
/// The tapped thread's hand (right or left).
pub hand: solidcraft_engine::doc::thread_forms::ThreadHand,
}
impl Default for HoleOpts {
@@ -430,6 +432,7 @@ impl Default for HoleOpts {
refs: Default::default(),
thread_full_depth: true,
thread_depth: "10 mm".into(),
hand: solidcraft_engine::doc::thread_forms::ThreadHand::Right,
}
}
}
@@ -4403,6 +4406,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
if !opts.thread_full_depth {
params["thread_depth"] = json!(opts.thread_depth.trim());
}
if opts.hand != solidcraft_engine::doc::thread_forms::ThreadHand::Right {
params["hand"] = json!(opts.hand);
}
}
match *kind {
1 => {
@@ -5307,7 +5313,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.extra.insert("pull".into(), pt3(pull.0));
d
}
FeatureKind::Hole { position, at, direction, diameter, depth, hole, points, thread, thread_depth, to, references } => {
FeatureKind::Hole { position, at, direction, diameter, depth, hole, points, thread, thread_depth, hand, to, references } => {
let mut d = start("solid.hole")?;
let mut k = hole_defaults(s);
if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = &mut k {
@@ -5318,6 +5324,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
if let Some(th) = thread {
opts.tapped = true;
opts.thread = th.clone();
opts.hand = *hand;
}
opts.thread_full_depth = thread_depth.is_none();
if let Some(depth) = thread_depth {
+1
View File
@@ -526,6 +526,7 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
});
if opts.tapped {
enter |= crate::dialogs_thread::hole_rows(ui, &app.session.doc, &mut opts.thread, &mut opts.custom_thread);
crate::dialogs_thread::hole_hand_row(ui, &mut opts.hand);
ui_kit::checkbox_grid(ui, &mut [("Full thread depth", &mut opts.thread_full_depth)], 1);
}
// Through all, the tip lies past the part.
+7
View File
@@ -166,6 +166,8 @@ const FORMS: &[(&str, ThreadForm)] = &[
("Buttress (ASME B1.9)", ThreadForm::ButtressAsme),
];
const HANDS: &[(&str, ThreadHand)] = &[("Right", ThreadHand::Right), ("Left", ThreadHand::Left), ("Both", ThreadHand::Both)];
/// A tapped hole's hands: one groove, so never both.
const HOLE_HANDS: &[(&str, ThreadHand)] = &[("Right", ThreadHand::Right), ("Left", ThreadHand::Left)];
const STARTS: &[(&str, u32)] = &[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("5", 5), ("6", 6), ("7", 7), ("8", 8)];
const FULL: usize = 0;
const LENGTH: usize = 1;
@@ -321,6 +323,11 @@ fn change_form(designation: &mut String, suggested: &mut Option<String>, from: T
}
/// A choice row over typed values.
/// The hole dialog's Hand row for a tapped hole, the same choice the thread dialog offers.
pub(crate) fn hole_hand_row(ui: &mut egui::Ui, hand: &mut ThreadHand) {
pick(ui, "Hand", "hole_hand", hand, HOLE_HANDS);
}
fn pick<T: Copy + PartialEq>(ui: &mut egui::Ui, label: &str, id: &str, value: &mut T, choices: &[(&str, T)]) {
let labels: Vec<&str> = choices.iter().map(|c| c.0).collect();
let mut index = choices.iter().position(|c| c.1 == *value).unwrap_or(0);
+7 -1
View File
@@ -35,6 +35,8 @@ enum Extent {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
struct ThreadOptions {
/// `right` or `left`, under the name v0.24.0 saved it with.
handedness: String,
full_length: bool,
depth: Option<String>,
#[serde(flatten)]
@@ -43,7 +45,7 @@ struct ThreadOptions {
impl Default for ThreadOptions {
fn default() -> Self {
Self { full_length: true, depth: None, unknown: BTreeMap::new() }
Self { handedness: "right".into(), full_length: true, depth: None, unknown: BTreeMap::new() }
}
}
@@ -106,6 +108,7 @@ 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))
|| !["right", "left"].contains(&self.thread_options.handedness.as_str())
|| self.thread_options.unknown.len() > 8
|| self.thread_options.unknown.iter().any(|(key, value)| {
key.is_empty()
@@ -183,6 +186,7 @@ impl Preset {
thread: opts.thread.clone(),
thread_options: if opts.tapped {
ThreadOptions {
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)?) },
unknown: BTreeMap::new(),
@@ -262,6 +266,8 @@ impl Preset {
opts.thread.clone_from(&self.thread);
opts.custom_thread = None;
opts.thread_full_depth = self.thread_options.full_length;
opts.hand =
if self.thread_options.handedness == "left" { solidcraft_engine::doc::thread_forms::ThreadHand::Left } else { solidcraft_engine::doc::thread_forms::ThreadHand::Right };
opts.thread_depth = self.thread_options.depth.clone().unwrap_or_else(|| crate::dialogs::HoleOpts::default().thread_depth);
// 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.