mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
* 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:
committed by
GitHub
parent
da16362626
commit
47285ccb88
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 個のフィーチャーを削除",
|
||||
|
||||
@@ -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} 个特征",
|
||||
|
||||
@@ -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
@@ -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, §ion, 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, §ion, 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.
|
||||
@@ -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}))
|
||||
|
||||
@@ -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, ¶ms).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]
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user