thread: main's screw direction (#4414) out; the thread's hand is the one direction

The reworked thread has its own direction, `hand: right|left|both`, on SMLib's
exact screw sweep. Main's #4414 added a second one, `handedness: right|left`, on
Hole and Thread, for the faceted groove this branch replaces. It is taken out: the
field, its pending format change (never released), the hole dialog's direction row,
its hole-preset option, its strings and its tests, which measured the faceted
groove. A tapped hole is right-handed, as before #4414.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoZgkCdAVrscuw2kjQTWDs
This commit is contained in:
jyviko
2026-10-26 19:21:58 -04:00
parent a57ce13e70
commit 6b9f5e0e49
16 changed files with 17 additions and 296 deletions
-3
View File
@@ -2254,10 +2254,7 @@
"the parts library is not available": "the parts library is not available",
"thread.automatic_size": "Automatic size",
"thread.designation": "Designation",
"thread.direction": "Thread direction",
"thread.iso_metric": "ISO metric",
"thread.left_hand": "Left-hand",
"thread.right_hand": "Right-hand",
"thread.select_size": "Select size",
"timeline.delete_after_marker_one": "Delete 1 Feature After History Marker",
"timeline.delete_after_marker_other": "Delete {count} Features After History Marker",
-3
View File
@@ -1153,10 +1153,7 @@
"the parts library is not available": "la libreria delle parti non è disponibile",
"thread.automatic_size": "Dimensione automatica",
"thread.designation": "Designazione",
"thread.direction": "Verso del filetto",
"thread.iso_metric": "Metrica ISO",
"thread.left_hand": "Sinistro",
"thread.right_hand": "Destro",
"thread.select_size": "Seleziona dimensione",
"timeline.group": "Nuova cartella di lavorazioni",
"timeline.move_many": "Sposta elementi della sequenza temporale",
-3
View File
@@ -2254,10 +2254,7 @@
"the parts library is not available": "パーツライブラリは利用できません",
"thread.automatic_size": "サイズを自動選択",
"thread.designation": "ねじの呼び",
"thread.direction": "ねじの方向",
"thread.iso_metric": "ISOメートルねじ",
"thread.left_hand": "左ねじ",
"thread.right_hand": "右ねじ",
"thread.select_size": "サイズを選択",
"timeline.delete_after_marker_one": "履歴マーカー以降の 1 個のフィーチャーを削除",
"timeline.delete_after_marker_other": "履歴マーカー以降の {count} 個のフィーチャーを削除",
-3
View File
@@ -2254,10 +2254,7 @@
"the parts library is not available": "零件库不可用",
"thread.automatic_size": "自动尺寸",
"thread.designation": "螺纹标记",
"thread.direction": "螺纹方向",
"thread.iso_metric": "ISO 公制",
"thread.left_hand": "左旋",
"thread.right_hand": "右旋",
"thread.select_size": "选择尺寸",
"timeline.delete_after_marker_one": "删除历史标记之后的 1 个特征",
"timeline.delete_after_marker_other": "删除历史标记之后的 {count} 个特征",
-20
View File
@@ -15,21 +15,6 @@ pub const MAX_PARAMS: usize = 10_000;
/// numbers, and far from overflowing the next id.
pub const MAX_FEATURE_ID: u64 = 1 << 52;
/// Screw direction independent of the cylinder's placement and the picked end.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(rename_all = "lowercase")]
pub enum ThreadHandedness {
#[default]
Right,
Left,
}
impl ThreadHandedness {
pub fn is_right(&self) -> bool {
*self == Self::Right
}
}
/// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…)
/// are created by dimensions and features.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -843,9 +828,6 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Thread depth")]
thread_depth: Option<Expr>,
/// Screw direction of the cosmetic thread; older holes are right-handed.
#[serde(default, skip_serializing_if = "ThreadHandedness::is_right")]
handedness: ThreadHandedness,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<FacePick>,
@@ -868,8 +850,6 @@ pub enum FeatureKind {
length: Option<Expr>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
#[serde(default, skip_serializing_if = "ThreadHandedness::is_right")]
handedness: ThreadHandedness,
/// A point near the end of the face the thread starts at; `None`: the face's only free
/// end (no material past it), or the end the pick at `face` lies near.
#[serde(default, skip_serializing_if = "Option::is_none")]
+2 -4
View File
@@ -6283,7 +6283,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, handedness, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, .. } => {
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()));
@@ -6310,9 +6310,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))
};
// A tapped hole's screw direction, as the thread's hand.
let hand = if handedness.is_right() { ThreadHand::Right } else { ThreadHand::Left };
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand, ..Default::default() };
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, ..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;
+1 -15
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); handedness?: right|left (default right, needs thread for left); 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); 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")
@@ -2412,10 +2412,6 @@ 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 handedness = thread_handedness(p, cmd)?;
if thread.is_none() && !handedness.is_right() {
return Err(bad(cmd, "`handedness` needs `thread` (a tapped hole)"));
}
if let Some(t) = &thread
&& solidcraft_doc::parse_tapped_thread(t).is_none()
{
@@ -2588,7 +2584,6 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
points: points.map(|x| x.0),
thread,
thread_depth,
handedness,
to: to.map(FacePick),
references,
},
@@ -2894,13 +2889,6 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Loft { sections, operation, targets, rails, rail_fit })
}
fn thread_handedness(p: &Value, cmd: &str) -> Result<solidcraft_doc::ThreadHandedness> {
match p.get("handedness") {
None => Ok(solidcraft_doc::ThreadHandedness::Right),
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`handedness` must be `right` or `left`")),
}
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.thread";
let face = p
@@ -3000,13 +2988,11 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(cmd, msg));
}
let handedness = thread_handedness(p, cmd)?;
let kind = FeatureKind::Thread {
face: FacePick(face),
designation,
length,
modeled,
handedness,
from,
up_to,
form,
-143
View File
@@ -150,149 +150,6 @@ fn tapped_thread_depth_rejects_invalid_or_excessive_values_atomically() {
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn thread_handedness_cosmetic_defaults_and_save_reopen_keep_axis_and_extent() {
use solidcraft_doc::{Document, FeatureKind, ThreadHandedness};
for (hole, designation) in [(false, "M6"), (true, "M6"), (true, "G 1/4"), (true, "1/4-20 UNC")] {
let mut reference = None;
for hand in [None, Some("right"), Some("left")] {
let mut s = Session::default();
let (command, mut params) = if hole {
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
(
"solid.hole",
json!({"position":[10,10,10],"thread":designation,"depth":6,"thread_depth":3,
"references":[{"edge":[0,10,10],"distance":"7 mm"},{"edge":[10,0,10],"distance":"8 mm"}]}),
)
} else {
run(&mut s, "solid.cylinder", json!({"radius":3,"height":6}));
("solid.thread", json!({"face":[3,0,2],"designation":"M6","length":4}))
};
if let Some(hand) = hand {
params["handedness"] = json!(hand);
}
run(&mut s, command, params);
let expected = if hand == Some("left") { ThreadHandedness::Left } else { ThreadHandedness::Right };
let info = s.model.state().threads[0].clone();
assert_eq!(info.handedness, expected);
if hole {
assert!((info.end - info.start - 3.0).abs() < 1e-8, "direction must retain the independent thread depth");
}
let dimensions = (info.axis_point, info.axis, info.start, info.end, info.major_diameter, info.pitch);
assert_eq!(*reference.get_or_insert(dimensions), dimensions, "direction must not change axis, length or size");
let mut kind = serde_json::to_value(&s.doc.features.last().unwrap().kind).unwrap();
assert_eq!(kind.get("handedness"), (expected == ThreadHandedness::Left).then(|| json!("left")).as_ref());
// An old feature with no field still deserializes to Right, for both feature types.
kind.as_object_mut().unwrap().remove("handedness");
let old: FeatureKind = serde_json::from_value(kind).unwrap();
assert!(matches!(
old,
FeatureKind::Hole { handedness: ThreadHandedness::Right, .. } | FeatureKind::Thread { handedness: ThreadHandedness::Right, .. }
));
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(reopened.model.state().threads[0], info);
}
}
}
#[test]
fn thread_handedness_invalid_values_fail_without_changing_the_document() {
for hole in [false, true] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":3,"height":6}));
let saved = s.doc.to_json();
for hand in [json!("clockwise"), json!("LEFT"), json!(null), json!(true), json!(1)] {
let (command, params) = if hole {
("solid.hole", json!({"position":[0,0,6],"thread":"M6","handedness":hand}))
} else {
("solid.thread", json!({"face":[3,0,2],"designation":"M6","handedness":hand}))
};
assert!(s.execute(command, &params).unwrap_err().to_string().contains("handedness"));
assert_eq!(s.doc.to_json(), saved);
}
if hole {
assert!(s.execute("solid.hole", &json!({"position":[0,0,6],"diameter":2,"handedness":"left"})).is_err());
assert_eq!(s.doc.to_json(), saved);
}
}
}
#[test]
fn thread_handedness_modelled_internal_and_external_keep_chirality_and_bounded_volume() {
use solidcraft_doc::Document;
for internal in [false, true] {
let mut bodies = Vec::new();
for hand in ["right", "left"] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":if internal {6.0} else {3.0},"height":4,"body_name":"Stock"}));
if internal {
run(&mut s, "solid.hole", json!({"position":[0,0,4],"diameter":5}));
}
let face = json!([if internal { 2.5 } else { 3.0 }, 0, 3.5]);
run(&mut s, "solid.thread", json!({"face":face,"designation":"M6x1","length":3,"modeled":true,"handedness":hand}));
let state = s.model.state();
let body = state.body("Stock").unwrap().body.clone();
assert!(body.validity().is_empty(), "{internal}/{hand}: {:?}", body.validity());
let before = volume(&mut s, "Stock");
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
assert!((volume(&mut reopened, "Stock") - before).abs() < 1e-8);
let brep_volume = crate::kernel::measure(&body).unwrap().volume;
bodies.push((body, before, brep_volume));
}
let [(right, vr, br), (left, vl, bl)] = bodies.as_slice() else { panic!("two directions") };
eprintln!("handedness internal={internal}: mesh_mm3={vr}/{vl}, brep_mm3={br}/{bl}");
for body in [right, left] {
let mesh = body.tessellate(0.001).unwrap();
let low = mesh.positions.iter().map(|p| p.z).fold(f64::INFINITY, f64::min);
let high = mesh.positions.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max);
assert!(low.abs() < 1e-8 && (high - 4.0).abs() < 1e-8, "physical axial bounds: {low}/{high}");
for z in [0.25, 1.5, 2.5, 3.75] {
assert_eq!(body.contains(Vec3::new(0.0, 0.0, z)), !internal, "axis stays solid in a shaft and open in a bore");
if internal {
for i in 0..24 {
let a = 2.0 * PI * i as f64 / 24.0;
assert!(body.contains(Vec3::new(4.5 * a.cos(), 4.5 * a.sin(), z)), "the outer wall remains whole");
}
}
}
}
let radius = if internal { 2.75 } else { 2.65 };
let mut different = 0;
for z in [0.4, 1.1, 1.4, 1.8, 2.2, 2.6, 3.1, 3.5] {
for i in 0..24 {
let angle = 2.0 * PI * (i as f64 + 0.17) / 24.0;
let p = Vec3::new(radius * angle.cos(), radius * angle.sin(), z);
let reflected = Vec3::new(-p.x, p.y, p.z);
assert_eq!(right.contains(p), left.contains(reflected), "actual mirrored helix: {internal} at {p:?}");
different += usize::from(right.contains(p) != left.contains(p));
if z < 1.0 {
assert!(right.contains(p), "the 3 mm thread must leave the lower stub unchanged");
}
}
}
assert!(different > 20, "Left must change real geometry, not just metadata: {internal}: {different}");
// The sweep itself is an exact reflection (doc test). The existing Boolean first
// facets the curved stock within pitch * .005 mm. A nominal stock-faceting
// regression envelope uses radial annuli [r-d, r+d], with flat caps unchanged:
// pi*h*((r+d)^2-(r-d)^2) = 4*pi*h*r*d, summed over the outer and bore cylinders.
// Exact-set cutting would not enlarge the stock's symmetric difference. The BSP
// and rebuild also use numerical epsilons, so this conservative secondary ceiling
// is not a rigorous bound on every Boolean error, nor a precision/equal-volume proof.
let delta = 1.0 * 0.005;
let radii = if internal { 6.0 + 2.5 } else { 3.0 };
let budget = 4.0 * PI * 4.0 * radii * delta;
eprintln!(
"handedness internal={internal}: post_boolean_delta_mm3={}, stock_faceting_budget_mm3={budget}, relative_delta={}",
(br - bl).abs(),
(br - bl).abs() / br
);
assert!(
(br - bl).abs() <= budget,
"post-Boolean volume exceeds the configured stock faceting envelope: {internal}: {br}/{bl}, budget {budget}"
);
}
}
/// Keep the original 48-station reproduction of the shell error even though new fine
/// threads now use more stations. The same cut polygons must rebuild as a closed solid.
#[test]
+2 -11
View File
@@ -10,9 +10,7 @@ use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::Session;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::doc::{
AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel, ThinSide, ThreadHandedness,
};
use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel, ThinSide};
use solidcraft_engine::geom::{Aabb3, Vec3};
use crate::SolveApp;
@@ -240,7 +238,6 @@ crate::dialog_descriptor::dialog_descriptors! {
Thread {
designation: String,
length: String,
handedness: ThreadHandedness,
/// The last automatic size; a different value is a manual override. Keep automatic
/// features automatic when applying, even though their resolved size is visible.
suggested: Option<String>,
@@ -418,7 +415,6 @@ pub struct HoleOpts {
/// Use the full useful cylindrical wall, independently of the drill extents.
pub thread_full_depth: bool,
pub thread_depth: String,
pub handedness: ThreadHandedness,
}
impl Default for HoleOpts {
@@ -434,7 +430,6 @@ impl Default for HoleOpts {
refs: Default::default(),
thread_full_depth: true,
thread_depth: "10 mm".into(),
handedness: ThreadHandedness::Right,
}
}
}
@@ -4408,9 +4403,6 @@ 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.handedness.is_right() {
params["handedness"] = json!(opts.handedness);
}
}
match *kind {
1 => {
@@ -5315,7 +5307,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, handedness, to, references } => {
FeatureKind::Hole { position, at, direction, diameter, depth, hole, points, thread, thread_depth, 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 {
@@ -5323,7 +5315,6 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
opts.all = depth.is_none();
opts.angled = matches!(hole, HoleKind::Drilled { .. });
opts.multiple = points.as_ref().is_some_and(|sp| !sp.single);
opts.handedness = *handedness;
if let Some(th) = thread {
opts.tapped = true;
opts.thread = th.clone();
-1
View File
@@ -527,7 +527,6 @@ 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);
ui_kit::checkbox_grid(ui, &mut [("Full thread depth", &mut opts.thread_full_depth)], 1);
crate::dialogs_thread::handedness_row(ui, &mut opts.handedness, "hole_handedness");
}
// Through all, the tip lies past the part.
if !opts.all {
+2 -10
View File
@@ -7,8 +7,8 @@
use serde_json::{Value, json};
use solidcraft_engine::doc::thread_forms::{SteepFlank, ThreadForm, ThreadHand, ThreadTip};
use solidcraft_engine::doc::{
Document, Expr, FeatureKind, TappedThreadFamily, ThreadHandedness, ThreadLimit, ThreadRunout, metric_thread_sizes, parse_metric_thread,
parse_tapped_thread, tapped_thread_sizes,
Document, Expr, FeatureKind, TappedThreadFamily, ThreadLimit, ThreadRunout, metric_thread_sizes, parse_metric_thread, parse_tapped_thread,
tapped_thread_sizes,
};
use solidcraft_engine::{Sel, Session};
@@ -156,14 +156,6 @@ pub(crate) fn hole_rows(ui: &mut egui::Ui, doc: &Document, designation: &mut Str
enter || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
}
pub(crate) fn handedness_row(ui: &mut egui::Ui, handedness: &mut ThreadHandedness, id: &str) {
let mut selected = usize::from(*handedness == ThreadHandedness::Left);
form_row(ui, "thread.direction", |ui| {
combo(ui, id, &["thread.right_hand", "thread.left_hand"], &mut selected);
});
*handedness = if selected == 1 { ThreadHandedness::Left } else { ThreadHandedness::Right };
}
/// The forms offered. The square thread is left out: no standard defines it, and the command
/// refuses it ([`ThreadForm::refused`]).
const FORMS: &[(&str, ThreadForm)] = &[
+4 -10
View File
@@ -880,8 +880,7 @@ fn a_face_place_snaps_to_the_points_of_shown_geometry_only() {
}
#[test]
fn combined_recessed_point_presets_keep_left_partial_across_units_and_legacy_defaults() {
use solidcraft_engine::doc::ThreadHandedness;
fn combined_recessed_point_presets_keep_partial_depth_across_units_and_legacy_defaults() {
for recessed in [1, 2] {
let mut h = personal_preset_harness();
if let Kind::Hole { diameter, kind, opts, .. } = &mut h.app.dialog.as_mut().unwrap().kind {
@@ -891,14 +890,12 @@ fn combined_recessed_point_presets_keep_left_partial_across_units_and_legacy_def
opts.tip_angle = "118 deg".into();
opts.tapped = true;
opts.thread = "M8x1.2".into();
opts.handedness = ThreadHandedness::Left;
opts.thread_full_depth = false;
opts.thread_depth = "5".into();
}
save_preset(&mut h, "Combined point");
let stored = serde_json::to_value(&h.app.preferences.hole_presets).unwrap();
assert_eq!(stored[0]["angled_bottom"], true);
assert_eq!(stored[0]["thread_options"]["handedness"], "left");
assert_eq!(stored[0]["thread_options"]["depth"], "5 mm");
h.app.preferences.hole_presets = serde_json::from_value(stored.clone()).unwrap();
h.app.session.doc_mut().units = "in".into();
@@ -906,18 +903,17 @@ fn combined_recessed_point_presets_keep_left_partial_across_units_and_legacy_def
if let Kind::Hole { kind, opts, .. } = &mut h.app.dialog.as_mut().unwrap().kind {
*kind = 0;
opts.angled = false;
opts.handedness = ThreadHandedness::Right;
opts.thread_full_depth = true;
}
let before = h.app.session.doc.to_json();
choose_preset(&mut h, "Combined point");
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Hole {kind,opts,..}
if *kind==recessed && opts.angled && opts.tip_angle=="118 deg" && opts.handedness==ThreadHandedness::Left
if *kind==recessed && opts.angled && opts.tip_angle=="118 deg"
&& !opts.thread_full_depth && opts.thread_depth=="5 mm" && opts.thread=="M8x1.2"
&& opts.custom_thread.as_ref().is_none_or(|draft| draft.error.is_none())));
assert_eq!(h.app.session.doc.to_json(), before, "automatic recall only changes the form");
assert!((h.app.session.doc.eval("5 mm", solidcraft_engine::doc::expr::Kind::Length).unwrap() - 5.0).abs() < 1e-9);
// Old recessed presets omit the additive fields: flat, Right and Full remain their defaults.
// Old recessed presets omit the additive fields: flat and Full remain their defaults.
choose_preset(&mut h, "Select a preset");
let mut legacy = stored.clone();
legacy[0].as_object_mut().unwrap().remove("angled_bottom");
@@ -925,7 +921,7 @@ fn combined_recessed_point_presets_keep_left_partial_across_units_and_legacy_def
h.app.preferences.hole_presets = serde_json::from_value(legacy).unwrap();
choose_preset(&mut h, "Combined point");
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Hole {kind,opts,..}
if *kind==recessed && !opts.angled && opts.handedness==ThreadHandedness::Right && opts.thread_full_depth));
if *kind==recessed && !opts.angled && opts.thread_full_depth));
choose_preset(&mut h, "Select a preset");
let mut future = stored;
future[0]["thread_options"]["starts"] = json!(2);
@@ -998,7 +994,6 @@ fn hole_presets_dirty_overwrite_keeps_name_and_all_pointed_left_partial_paramete
opts.angled = true;
opts.tapped = true;
opts.thread = "M8".into();
opts.handedness = solidcraft_engine::doc::ThreadHandedness::Left;
opts.thread_full_depth = false;
opts.thread_depth = "5 mm".into();
}
@@ -1011,7 +1006,6 @@ fn hole_presets_dirty_overwrite_keeps_name_and_all_pointed_left_partial_paramete
assert_eq!(saved[0]["name"], "Workshop");
assert_eq!(saved[0]["depth"], "13 mm");
assert_eq!(saved[0]["angled_bottom"], true);
assert_eq!(saved[0]["thread_options"]["handedness"], "left");
assert_eq!(saved[0]["thread_options"]["depth"], "5 mm");
assert!(!preset_dirty_save_enabled(&h));
h.step(&json!({"expect":{"hidden_handles":["dialog:hole_presets.name"],"not_shown":"* Workshop"}})).unwrap();
+1 -9
View File
@@ -35,7 +35,6 @@ enum Extent {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
struct ThreadOptions {
handedness: String,
full_length: bool,
depth: Option<String>,
#[serde(flatten)]
@@ -44,7 +43,7 @@ struct ThreadOptions {
impl Default for ThreadOptions {
fn default() -> Self {
Self { handedness: "right".into(), full_length: true, depth: None, unknown: BTreeMap::new() }
Self { full_length: true, depth: None, unknown: BTreeMap::new() }
}
}
@@ -106,7 +105,6 @@ impl Preset {
}
}
if required.into_iter().any(|text| text.trim().is_empty())
|| !["right", "left"].contains(&self.thread_options.handedness.as_str())
|| self.thread_options.depth.as_ref().is_some_and(|text| !valid_text(text, MAX_EXPRESSION))
|| self.thread_options.unknown.len() > 8
|| self.thread_options.unknown.iter().any(|(key, value)| {
@@ -185,7 +183,6 @@ impl Preset {
thread: opts.thread.clone(),
thread_options: if opts.tapped {
ThreadOptions {
handedness: if opts.handedness.is_right() { "right".into() } else { "left".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(),
@@ -264,11 +261,6 @@ impl Preset {
opts.tapped = self.tapped;
opts.thread.clone_from(&self.thread);
opts.custom_thread = None;
opts.handedness = if self.thread_options.handedness == "left" {
solidcraft_engine::doc::ThreadHandedness::Left
} else {
solidcraft_engine::doc::ThreadHandedness::Right
};
opts.thread_full_depth = self.thread_options.full_length;
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:
+2 -2
View File
@@ -3491,10 +3491,10 @@ mod tests {
use serde_json::json;
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let mut row = hole_preset_row("Fixture");
row["thread_options"] = json!({"handedness":"left","full_length":false,"depth":"4 mm"});
row["thread_options"] = json!({"full_length":false,"depth":"4 mm"});
super::set(&mut app, &json!({"hole_presets":[row]})).unwrap();
let expected = serde_json::to_value(&app.preferences.hole_presets).unwrap();
assert_eq!(expected[0]["thread_options"]["handedness"], "left");
assert_eq!(expected[0]["thread_options"]["depth"], "4 mm");
for page in 0..super::PAGES.len() {
super::reset_preferences(&mut app.preferences, page);
}
-52
View File
@@ -1309,58 +1309,6 @@ fn thread_size_suggestion_can_be_cleared_and_edited_in_the_dialog() {
assert_eq!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["designation"], "M8");
}
#[test]
fn thread_handedness_choices_create_edit_and_restore_the_saved_direction() {
use solidcraft_engine::doc::{FeatureKind, ThreadHandedness};
let mut h = crate::scenario::Harness::new();
h.app.run("solid.cylinder", json!({"radius":3,"height":6})).unwrap();
h.app.session.selection = vec![thread_face(&h.app, "Body1")];
h.step(&json!({"start":"solid.thread"})).unwrap();
h.frames(2);
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, crate::dialogs::Kind::Thread { handedness: ThreadHandedness::Right, .. }));
h.step(&json!({"click":{"handle":"dialog:thread.direction"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:thread.direction:thread.left_hand"}})).unwrap();
let command = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(command[0].1["handedness"], "left");
h.step(&json!({"widget":"OK"})).unwrap();
let id = h.app.session.doc.features.last().unwrap().id;
h.call("ui.editFeature", json!({"feature":id}));
h.frames(2);
assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, crate::dialogs::Kind::Thread { handedness: ThreadHandedness::Left, .. }));
h.step(&json!({"click":{"handle":"dialog:thread.direction"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:thread.direction:thread.right_hand"}})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
assert!(matches!(&h.app.session.doc.feature(id).unwrap().kind, FeatureKind::Thread { handedness: ThreadHandedness::Right, .. }));
assert_eq!(h.app.session.model.state().threads[0].handedness, ThreadHandedness::Right);
}
#[test]
fn thread_handedness_tapped_hole_edit_retains_and_changes_direction() {
use solidcraft_engine::doc::{FeatureKind, ThreadHandedness};
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":20,"width":20,"height":10})).unwrap();
let id =
h.app.run("solid.hole", json!({"position":[10,10,10],"thread":"M6","depth":6,"thread_depth":"3 mm","handedness":"left"})).unwrap()["feature"]
.as_u64()
.unwrap();
h.call("ui.editFeature", json!({"feature":id}));
h.frames(2);
assert!(
matches!(&h.app.dialog.as_ref().unwrap().kind, crate::dialogs::Kind::Hole { opts, .. } if opts.handedness == ThreadHandedness::Left && !opts.thread_full_depth && opts.thread_depth == "3 mm")
);
h.step(&json!({"click":{"handle":"dialog:thread.direction"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:thread.direction:thread.right_hand"}})).unwrap();
let command = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(command[0].0, "timeline.redefine");
assert_eq!(command[0].1["command"], "solid.hole");
assert_eq!(command[0].1["params"]["thread_depth"], "3 mm", "editing direction must retain the independent thread depth");
h.step(&json!({"widget":"OK"})).unwrap();
assert!(matches!(&h.app.session.doc.feature(id).unwrap().kind, FeatureKind::Hole { handedness: ThreadHandedness::Right, .. }));
let state = h.app.session.model.state();
assert_eq!(state.threads[0].handedness, ThreadHandedness::Right);
assert!((state.threads[0].end - state.threads[0].start - 3.0).abs() < 1e-8);
}
#[test]
fn modeled_thread_survives_timeline_double_click_and_ok() {
for (analytic_pick, change_length) in [(false, false), (false, true), (true, false), (true, true)] {
+3 -7
View File
@@ -138,13 +138,9 @@ The Hole dialog draws a section through the hole as its options make it (type, e
point, tapped) and puts each size in a field beside the dimension it sets; hovering a field, or
typing in it, lights its dimension.
Tapped holes and **Thread** features have a **Thread direction** choice: **Right-hand**
(the default) or **Left-hand**. Editing and reopening keep the chosen direction. Tapped
holes carry a cosmetic thread; the separate Thread feature can also cut a **Modeled**
left-hand helix. Cosmetic direction is recorded with the thread annotation and does not
change the body. Scripts use `handedness: "left"` or `"right"` on `solid.hole` (with `thread`)
and `solid.thread`. Existing designs and omitted values remain right-handed. Changing the
direction keeps the pitch, thread length and axis; it does not change a hole's drill size.
The **Thread** feature's **Hand** is **Right** (the default), **Left**, or **Both** (a right- and
a left-handed groove crossing, as on a reversing screw). Scripts use `hand: "left"` (or `"right"`,
`"both"`) on `solid.thread`; omitted, a thread is right-handed. Tapped holes are right-handed.
Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of
neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling