mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 02:55:54 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 個のフィーチャーを削除",
|
||||
|
||||
@@ -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} 个特征",
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¶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,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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)] = &[
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user