Thread: right- and left-handed direction (#4414) (#4415)

* claim: #4414

* feat: persist right and left thread handedness (#4414)

* fix(thread): verify handedness with referenced holes and complete locales

* test: verify handedness with merged tapped thread catalogues

* test: retain tapped thread depth when changing handedness (#4414)

* test: restrict Unix symlink coverage to Unix targets

* test: inspect hole thread depth inside edit command parameters (#4414)

* test(doc): initialize cylinder radius in handedness fixture
This commit is contained in:
amolinari-autotex
2026-10-26 13:57:08 +00:00
committed by GitHub
parent da16362626
commit 47285ccb88
14 changed files with 364 additions and 18 deletions
+3
View File
@@ -2189,7 +2189,10 @@
"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
@@ -1131,7 +1131,10 @@
"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
@@ -2189,7 +2189,10 @@
"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
@@ -2189,7 +2189,10 @@
"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,6 +15,21 @@ 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)]
@@ -764,6 +779,9 @@ 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>,
@@ -786,6 +804,8 @@ 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,
},
/// Ruled loft through profiles of several sketches, in order.
Loft {
+72 -8
View File
@@ -348,6 +348,8 @@ pub struct ThreadInfo {
pub radius: f64,
pub pitch: f64,
pub internal: bool,
#[serde(default)]
pub handedness: crate::ThreadHandedness,
pub axis_point: Vec3,
pub axis: UnitVec3,
/// Threaded part along the axis from `axis_point`.
@@ -422,13 +424,21 @@ pub(crate) fn format_thread(d: f64, p: f64) -> String {
/// The automatic ISO metric designation for a cylindrical face, using the same fit check as
/// a thread feature without an explicit size. This query does not change the model.
pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result<String> {
thread_on(st, 0, p, None, None, false).map(|t| t.designation)
thread_on(st, 0, p, None, None, false, crate::ThreadHandedness::Right).map(|t| t.designation)
}
/// A cosmetic thread on the cylindrical face at `p`. A `turned` (modelled) thread also fits a
/// shaft thicker than it, which it turns down to its major diameter first.
fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>, turned: bool) -> Result<ThreadInfo> {
thread_on_impl(st, feature, p, designation, length, false, turned)
fn thread_on(
st: &ModelState,
feature: u64,
p: Vec3,
designation: Option<&str>,
length: Option<f64>,
turned: bool,
handedness: crate::ThreadHandedness,
) -> Result<ThreadInfo> {
thread_on_impl(st, feature, p, designation, length, false, turned, handedness)
}
// Tapped holes admit verified Unified and parallel pipe sizes; the separate Thread feature remains metric,
@@ -441,6 +451,7 @@ fn thread_on_impl(
length: Option<f64>,
tapped: bool,
turned: bool,
handedness: crate::ThreadHandedness,
) -> Result<ThreadInfo> {
let (body, cyl) = st
.bodies
@@ -501,6 +512,7 @@ fn thread_on_impl(
radius: cyl.radius,
pitch,
internal: cyl.internal,
handedness,
axis_point: cyl.axis_point,
axis: cyl.axis,
start: a,
@@ -591,7 +603,59 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
if !stations.is_finite() || stations > 720.0 {
return Err(DocError::Invalid("the modelled thread is too fine for this diameter; use a cosmetic thread".into()));
}
Ok(kernel::sweep_helix_mesh(&plane, &section, base, *k, p, (s1 - s0) / p, stations as usize)?)
// The kernel rotates positively about its sweep axis. Reversing both that axis and the
// pitch mirrors the angular motion while the lift stays +k * p per turn. The physical
// axis, start/end, radial profile and station count remain exactly as for a right thread.
let (sweep_axis, sweep_pitch) = match t.handedness {
crate::ThreadHandedness::Right => (*k, p),
crate::ThreadHandedness::Left => (-*k, -p),
};
Ok(kernel::sweep_helix_mesh(&plane, &section, base, sweep_axis, sweep_pitch, (s1 - s0) / p, stations as usize)?)
}
#[cfg(test)]
mod thread_handedness_tests {
use super::*;
#[test]
fn thread_handedness_groove_reflects_exactly_about_the_physical_axis() {
for direction in [Vec3::Z, Vec3::X, Vec3::new(1.0, 2.0, 3.0)] {
for internal in [false, true] {
let axis = UnitVec3::new(direction).unwrap();
let radius = if internal { 2.5 } else { 3.0 };
let mut info = ThreadInfo {
feature: 1,
body: "Stock".into(),
designation: "M6x1".into(),
major_diameter: 6.0,
radius,
pitch: 1.0,
internal,
handedness: crate::ThreadHandedness::Right,
axis_point: Vec3::new(7.0, -5.0, 2.0),
axis,
start: 1.3,
end: 4.3,
};
let right = thread_groove(&info, radius, (false, true)).unwrap();
info.handedness = crate::ThreadHandedness::Left;
let left = thread_groove(&info, radius, (false, true)).unwrap();
// Reflection in the radial plane of the initial profile, independent of the
// helix parameterization: the projected coordinate along the axis stays fixed.
let seed = if axis.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
let normal = (seed - *axis * axis.dot(seed)).normalized().unwrap();
assert_eq!(right.positions.len(), left.positions.len());
for (r, l) in right.positions.iter().zip(&left.positions) {
let expected = *r - normal * (2.0 * (*r - info.axis_point).dot(normal));
assert!((*l - expected).len() < 1e-10, "physical reflection: {direction:?}/{internal}");
assert!(((*r - *l).dot(*axis)).abs() < 1e-10, "axial extent must not change");
}
let (r, l) = (right.measure(), left.measure());
assert!(r.volume > 0.0 && l.volume > 0.0, "outward, watertight sweep");
assert!((r.volume - l.volume).abs() < r.volume * 1e-12, "exact tool volume: {r:?}/{l:?}");
}
}
}
}
/// What a modelled external thread turns off a shaft thicker than its major diameter before it
@@ -7046,7 +7110,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
apply_traced(doc, st, f, tools, *operation, &crate::plain(targets))
}
FeatureKind::Hole { thread, thread_depth, diameter, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, handedness, .. } => {
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()));
@@ -7073,7 +7137,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 mut t = thread_on_impl(st, f.id, wall, Some(d), None, true, false)?;
let mut t = thread_on_impl(st, f.id, wall, Some(d), None, true, false, *handedness)?;
if let Some(depth) = thread_depth {
let available = t.end - t.start;
if depth > available {
@@ -7096,12 +7160,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Thread { face, designation, length, modeled } => {
FeatureKind::Thread { face, designation, length, modeled, handedness } => {
let l = match length {
Some(e) => Some(val(vals, e, Kind::Length)?),
None => None,
};
let t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled)?;
let t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled, *handedness)?;
if !*modeled {
st.threads.push(t);
return Ok(());
@@ -0,0 +1,3 @@
//! Hole and Thread features may store `handedness: "left"`. An absent value still means
//! right-handed, so older designs need no rewriting. New-format gating prevents an older
//! reader from silently dropping the direction and rebuilding a left thread as right.
+16 -3
View File
@@ -68,7 +68,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); 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)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
@@ -92,7 +92,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; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); handedness?: right|left (default right); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
@@ -2327,6 +2327,10 @@ 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()
{
@@ -2499,6 +2503,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
points: points.map(|x| x.0),
thread,
thread_depth,
handedness,
to: to.map(FacePick),
references,
},
@@ -2782,6 +2787,13 @@ 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
@@ -2804,7 +2816,8 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(cmd, msg));
}
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled })?;
let handedness = thread_handedness(p, cmd)?;
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled, handedness })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
+143
View File
@@ -150,6 +150,149 @@ 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]
+24 -5
View File
@@ -10,7 +10,9 @@ 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};
use solidcraft_engine::doc::{
AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel, ThinSide, ThreadHandedness,
};
use solidcraft_engine::geom::{Aabb3, Vec3};
use crate::SolveApp;
@@ -237,6 +239,7 @@ 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>,
@@ -411,6 +414,7 @@ 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 {
@@ -426,6 +430,7 @@ impl Default for HoleOpts {
refs: Default::default(),
thread_full_depth: true,
thread_depth: "10 mm".into(),
handedness: ThreadHandedness::Right,
}
}
}
@@ -826,7 +831,13 @@ impl Dialog {
vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES | AXES, false)],
),
"solid.thread" => Dialog::new(
Kind::Thread { designation: String::new(), length: String::new(), suggested: None, of: Vec::new() },
Kind::Thread {
designation: String::new(),
length: String::new(),
handedness: ThreadHandedness::Right,
suggested: None,
of: Vec::new(),
},
vec![SelInput::new("Cylindrical face", FACES, false)],
),
"material.assign" => Dialog::new(Kind::Material { index: 1, editor: Default::default() }, vec![SelInput::new("Bodies", BODIES, true)]),
@@ -4140,13 +4151,16 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
need(0, "faces")?;
("solid.offset_face", json!({"faces": face_points(0)}))
}
Kind::Thread { designation, length, suggested, .. } => {
Kind::Thread { designation, length, suggested, handedness, .. } => {
need(0, "a cylindrical face")?;
let mut p = json!({"face": face_points(0).into_iter().next().unwrap_or(Value::Null)});
if !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) {
p["designation"] = json!(designation.trim());
}
put(&mut p, &[("length", length)]);
if !handedness.is_right() {
p["handedness"] = json!(handedness);
}
("solid.thread", p)
}
Kind::Material { index, editor } => {
@@ -4268,6 +4282,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.handedness.is_right() {
params["handedness"] = json!(opts.handedness);
}
}
match *kind {
1 => {
@@ -5171,7 +5188,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, handedness, 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 {
@@ -5179,6 +5196,7 @@ 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();
@@ -5656,7 +5674,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Thread { face, designation, length, modeled } => {
FeatureKind::Thread { face, designation, length, modeled, handedness } => {
let mut d = start("solid.thread")?;
if *modeled {
d.extra.insert("modeled".into(), json!(true));
@@ -5664,6 +5682,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
d.kind = Kind::Thread {
designation: designation.clone().unwrap_or_default(),
length: length.as_deref().map(str::to_string).unwrap_or_default(),
handedness: *handedness,
suggested: None,
of: Vec::new(),
};
+3 -1
View File
@@ -525,6 +525,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);
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 {
@@ -755,12 +756,13 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo
Kind::OffsetFaces { distance } => {
enter |= expr_row(ui, "Distance", distance);
}
Kind::Thread { designation, length, suggested, of } => {
Kind::Thread { designation, length, suggested, of, handedness } => {
let mut full = length.trim().is_empty();
let was_full = full;
let mut modeled = d.extra.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let before = modeled;
let (submitted, reset) = crate::dialogs_thread::rows(ui, designation, suggested, &mut modeled, &mut full);
crate::dialogs_thread::handedness_row(ui, handedness, "thread_handedness");
if modeled != before {
d.extra.insert("modeled".into(), json!(modeled));
}
+11 -1
View File
@@ -1,7 +1,9 @@
//! Tapped-hole catalogues and metric Thread choices share document sizing data.
//! Browsing choices does not edit the model or freeze an automatic Thread feature.
use solidcraft_engine::doc::{Document, TappedThreadFamily, metric_thread_sizes, parse_metric_thread, parse_tapped_thread, tapped_thread_sizes};
use solidcraft_engine::doc::{
Document, TappedThreadFamily, ThreadHandedness, metric_thread_sizes, parse_metric_thread, parse_tapped_thread, tapped_thread_sizes,
};
use crate::dialogs::{combo, form_row};
use crate::i18n::tr;
@@ -146,6 +148,14 @@ 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 };
}
/// Returns (Enter pressed, automatic sizing requested).
pub(crate) fn rows(ui: &mut egui::Ui, designation: &mut String, suggested: &mut Option<String>, modeled: &mut bool, full: &mut bool) -> (bool, bool) {
form_row(ui, "Profile", |ui| {
+52
View File
@@ -1248,6 +1248,58 @@ 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)] {
+8
View File
@@ -138,6 +138,14 @@ 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.
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
ball). See the roadmap's known limitations for the rest.