solid.hole: position takes length expressions and follows them (#1678) (#1683)

Thanks @DuBearte.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-16 08:26:23 -04:00
1 parent 838d0e86f2
commit dd5278a402
8 files changed
+128 -17

No files matched your search

+30 -3
View File
@@ -55,7 +55,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face | 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); 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); diameter | thread (a tapped hole: the tap drill, major − pitch) | 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); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
.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); 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); diameter | thread (a tapped hole: the tap drill, major − pitch) | 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); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
@@ -1633,10 +1633,36 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M6 or M6x0.75)")));
}
let position = match (&points, p.get("position").and_then(vec3)) {
// A position given as length expressions (#1678), say `["leg_h*0.4", "width/2", "t"]`: kept,
// so the hole follows the parameters; drilled where they evaluate now.
let at: Option<solidcraft_doc::PointExpr> = match p.get("position").and_then(Value::as_array) {
Some(a) if points.is_none() && a.len() == 3 && a.iter().any(Value::is_string) => {
let mut coords = Vec::with_capacity(3);
for v in a {
let e = match v {
Value::String(x) => x.trim().to_string(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, "`position` must be [x, y, z]: numbers or length expressions")),
};
check_expr(s, &e, Kind::Length, cmd, "position")?;
coords.push(solidcraft_doc::Expr::new(e));
}
let [x, y, z]: [solidcraft_doc::Expr; 3] = coords.try_into().map_err(|_| bad(cmd, "`position` must be [x, y, z]"))?;
Some([x, y, z])
}
_ => None,
};
let at_value = match &at {
Some([x, y, z]) => {
let c = |e: &solidcraft_doc::Expr| s.doc.eval(e, Kind::Length).map_err(|e| bad(cmd, format!("`position`: {e}")));
Some(Vec3::new(c(x)?, c(y)?, c(z)?)).filter(|v| v.is_finite())
}
None => None,
};
let position = match (&points, at_value.or_else(|| p.get("position").and_then(vec3))) {
(Some((_, first, _)), None) => *first,
(_, Some(x)) => x,
(None, None) => return Err(bad(cmd, "`position` must be [x, y, z] (or give `sketch` and `points`)")),
(None, None) => return Err(bad(cmd, "`position` must be [x, y, z], numbers or length expressions (or give `sketch` and `points`)")),
};
let direction = match (p.get("direction").and_then(vec3), &points) {
(None, Some((_, _, d))) => *d,
@@ -1706,6 +1732,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
&p,
FeatureKind::Hole {
position: FacePick(position),
at,
direction: Dir(direction),
diameter,
depth,
+35
View File
@@ -1217,6 +1217,41 @@ fn extrude_auto_operation_follows_the_to_object() {
assert_eq!(extrude_reach(&s, &json!({"face": [20, 15, 20], "distance": 5, "direction": "negative"})), Some(-5.0));
}
/// #1678: a hole's `position` takes length expressions, keeps them and follows the parameters: a
/// hole on a plate's middle line stays there when the plate gets wider. A position of plain
/// numbers is stored as before, with no `at`.
#[test]
fn a_hole_position_of_expressions_follows_the_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "width", "expression": "30 mm"}));
run(&mut s, "parameters.add", json!({"name": "t", "expression": "4 mm"}));
run(&mut s, "solid.box", json!({"length": 100, "width": "width", "height": "t", "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": ["40", "width/2", "t"], "clearance": "M5", "name": "Middle"}));
run(&mut s, "solid.hole", json!({"position": [80, 10, 4], "clearance": "M5", "name": "Fixed"}));
let centre_y = |s: &Session| {
let st = s.model.state();
solidcraft_kernel::measure(&st.body("Plate").unwrap().body).unwrap().centroid.y
};
let hole = |s: &Session, name: &str| -> Value {
let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
saved["features"].as_array().and_then(|f| f.iter().find(|f| f["name"] == name)).cloned().unwrap()
};
assert_eq!(hole(&s, "Middle")["at"], json!([40.0, "width/2", "t"]), "the expressions are kept");
assert!(hole(&s, "Fixed").get("at").is_none(), "plain numbers are stored as before");
// The fixed hole sits off the middle line, so it alone moves the centre; widen the plate.
let before = centre_y(&s);
run(&mut s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
let after = centre_y(&s);
// The middle hole is at y = 20 now (the plate's middle); the fixed one stays at y = 10. With
// the middle hole left at y = 15 the centre would sit lower still.
let plate = 100.0 * 40.0 * 4.0;
let hole_v = std::f64::consts::PI * (5.5f64 / 2.0).powi(2) * 4.0;
let want = (plate * 20.0 - hole_v * 20.0 - hole_v * 10.0) / (plate - 2.0 * hole_v);
assert!((after - want).abs() < 1e-3, "centre y {after}, want {want} (was {before})");
// A bad expression is refused.
assert!(s.execute("solid.hole", &json!({"position": ["40", "nope/2", "t"], "diameter": 3})).is_err());
}
/// Additive extrusions default to joining even when only a profile side touches material.
#[test]
fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() {