Align takes planes and the origin (#4248)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-21 08:06:32 -04:00
committed by GitHub
parent 93a2922c5e
commit e04a016e7c
7 changed files with 148 additions and 12 deletions
+9
View File
@@ -950,6 +950,15 @@ pub enum FeatureKind {
/// Normals the same way instead of facing each other.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// A plane the bodies move with (an origin or construction plane): its origin and normal
/// are the `from` point and normal, worked out again whenever the plane moves.
#[serde(default, skip_serializing_if = "Option::is_none")]
from_plane: Option<PlaneRef>,
/// A plane the bodies move onto: after turning to meet it (when `from` has a normal) the
/// `from` point is brought onto the plane along its normal, so the plane may move and the
/// bodies follow. Replaces `to`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to_plane: Option<PlaneRef>,
},
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
+20 -4
View File
@@ -116,19 +116,35 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane } => {
// A plane on either side is worked out now, so the bodies follow it when it moves.
let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized()));
if let Some(p) = from_plane {
let pl = doc.resolve_plane_in(vals, p, 0, Some(&*st))?;
(from, from_normal) = (pl.origin, Some(pl.normal()));
}
let target = to_plane.as_ref().map(|p| doc.resolve_plane_in(vals, p, 0, Some(&*st))).transpose()?;
let (to, to_normal) = match &target {
Some(pl) => (pl.origin, Some(pl.normal())),
None => (to.0, to_normal.and_then(|n| n.normalized())),
};
let mut m = translation(Vec3::ZERO);
if let (Some(a), Some(b)) = (from_normal.and_then(|n| n.normalized()), to_normal.and_then(|n| n.normalized())) {
if let (Some(a), Some(b)) = (from_normal, to_normal) {
// Turn `a` onto −b (faces meet) or b (flipped), about the `from` point.
let want = if *flip { b } else { b * -1.0 };
let axis = a.cross(want);
let ang = axis.len().atan2(a.dot(want));
let axis = axis.normalized().unwrap_or_else(|| a.any_perp());
if ang.abs() > 1e-12 {
m = rotation(from.0, axis, ang);
m = rotation(from, axis, ang);
}
}
let d = to.0 - from.0;
// Onto a plane the point goes straight along the plane's normal (nothing slides
// across it); onto a point it lands on it.
let d = match (&target, to_normal) {
(Some(_), Some(n)) => n * (to - from).dot(n),
_ => to - from,
};
m[3][0] += d.x;
m[3][1] += d.y;
m[3][2] += d.z;
@@ -0,0 +1,5 @@
//! An Align can name planes (`from_plane`, `to_plane`: an origin, construction or offset plane, or
//! an explicit one) instead of a point or a face's point and normal, so the bodies follow the plane
//! when it moves. Older designs need no transformation: an Align without them keeps the points and
//! normals it was saved with. The last release refuses a design saved with this (the newer format
//! number).
+30 -5
View File
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("align")
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
@@ -385,23 +385,46 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no body `{b}`")));
}
}
let pick = |s: &Session, pk: &str, fk: &str| -> Result<(Vec3, Option<Vec3>)> {
// A side is a plane (`from_plane`/`to_plane`: XY|XZ|YZ, a construction or offset plane, or
// {origin, normal}), kept as a plane so the bodies follow it when it moves; or a point and, for
// a face, its normal.
let plane = |s: &Session, key: &str| -> Result<Option<solidcraft_doc::PlaneRef>> {
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
};
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
if plane.is_some() {
if p.get(pk).is_some() || p.get(fk).is_some() {
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
}
// Worked out from the plane when the feature is evaluated.
return Ok((Vec3::ZERO, None));
}
if let Some(fp) = p.get(fk).and_then(vec3) {
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// The origin point.
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
};
let (from, from_normal) = pick(s, "from", "from_face")?;
let (to, to_normal) = pick(s, "to", "to_face")?;
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
let (from_normal, to_normal) = match (from_normal, to_normal) {
(Some(a), Some(b)) => (Some(a), Some(b)),
(a, b) if from_plane.is_some() || to_plane.is_some() => (a, b),
_ => (None, None),
};
let flip = bool_(p, "flip").unwrap_or(false);
@@ -415,6 +438,8 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
from_normal: from_normal.map(Dir),
to_normal: to_normal.map(Dir),
flip,
from_plane,
to_plane,
},
)
}
@@ -540,6 +540,57 @@ fn replace_face_align_remove() {
assert_eq!(measure(&mut s)["body_count"], 1);
}
/// The z range and the x minimum of body `name`.
fn z_x(s: &mut Session, name: &str) -> (f64, f64, f64) {
let m = measure(s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == name).unwrap().clone();
let g = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
(g("min", 2), g("max", 2), g("min", 0))
}
/// Align onto planes and the origin: a face goes flush onto an origin plane (turning to meet it),
/// onto an offset plane, a vertex onto the origin point, and a plane the bodies are aligned to
/// is followed when it moves.
#[test]
fn align_takes_planes_and_the_origin() {
let boxed = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 20], "body_name": "C"}));
s
};
// The bottom face onto XY: it already faces down, so it only drops; nothing slides across.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "XY"}));
let (lo, hi, x) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6 && (x - 50.0).abs() < 1e-6, "{lo} {hi} {x}");
// The top face onto XY: the body turns over (the face now faces down) and sits on the plane.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 30], "to_plane": "XY"}));
let (lo, hi, _) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{lo} {hi}");
assert!(measure(&mut s)["bodies"][0]["bbox"]["max"][2].as_f64().is_some());
// A vertex onto the origin point.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from": {"snap": "vertex", "at": [50, 0, 20]}, "to": "origin"}));
let (lo, _, x) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && x.abs() < 1e-6, "{lo} {x}");
// Onto an offset plane, and the bodies follow it when its distance changes.
let mut s = boxed();
run(&mut s, "parameters.add", json!({"name": "lift", "expression": "30 mm"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "lift", "name": "Shelf"}));
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "Shelf"}));
assert!((z_x(&mut s, "C").0 - 30.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": "lift", "expression": "45 mm"}));
assert!((z_x(&mut s, "C").0 - 45.0).abs() < 1e-6, "the bodies follow the plane");
// A plane as the source: the bodies move with it onto a point.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_plane": "XY", "to": [0, 0, 7]}));
assert!((z_x(&mut s, "C").0 - 27.0).abs() < 1e-6);
// One side is a plane or a point, not both; an unknown plane is refused.
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from_plane": "XY", "from": [0, 0, 0], "to": [0, 0, 7]})).is_err());
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from": [0, 0, 0], "to_plane": "Nowhere"})).is_err());
}
#[test]
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
+7 -3
View File
@@ -627,8 +627,8 @@ impl Dialog {
Kind::Align { flip: false },
vec![
SelInput::new("Bodies", BODIES, true),
SelInput::new("From", FACES | EDGES | selection::VERTICES, false),
SelInput::new("To", FACES | EDGES | selection::VERTICES, false),
SelInput::new("From", FACES | EDGES | selection::VERTICES | PLANES | selection::ORIGIN, false),
SelInput::new("To", FACES | EDGES | selection::VERTICES | PLANES | selection::ORIGIN, false),
],
),
"solid.remove" => Dialog::new(Kind::Remove, vec![SelInput::new("Bodies", BODIES, true)]),
@@ -3445,8 +3445,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
(Some(Sel::Face { point, .. }), _) => p[format!("{key}_face")] = pt(*point),
(Some(Sel::Edge { point, .. }), _) => p[key] = json!({"snap": "edge_mid", "at": pt(*point)}),
// The origin point (a vertex of no body) stays the origin.
(Some(Sel::Vertex { body, .. }), _) if body.is_empty() => p[key] = json!("origin"),
(Some(Sel::Vertex { point, .. }), _) => p[key] = pt(*point),
_ => return Err("pick faces, edges or vertices".into()),
// A plane stays a plane, so the bodies follow it when it moves.
(Some(Sel::Plane { name }), _) => p[format!("{key}_plane")] = json!(name),
_ => return Err("pick faces, edges, vertices or planes".into()),
}
}
("solid.align", p)
+26
View File
@@ -1488,6 +1488,32 @@ fn align_snaps_to_centres_and_edge_middles() {
assert_eq!(p["to"]["snap"], json!("edge_mid"));
}
/// The Align dialog takes a plane (viewport or browser click) and the origin plane stays a plane
/// in the command, so the bodies follow it when it moves.
#[test]
fn align_accepts_a_plane_click() {
let mut app = boxed();
let mut d = start(&mut app, vec![], "solid.align");
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
let plane = crate::viewport::Hit::Plane { name: "XZ".into(), point: Vec3::ZERO };
d.active = 2;
let sel = d.candidate(&app.session, &plane);
assert_eq!(sel, Some(Sel::Plane { name: "XZ".into() }), "the To input takes a plane");
d.pick(&app.session, sel.unwrap());
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert_eq!(p["to_plane"], json!("XZ"));
assert!(p.get("to").is_none() && p["from_face"].is_array());
// The origin point is a From/To too.
let origin = crate::viewport::Hit::Vertex { body: String::new(), point: Vec3::ZERO };
let sel = d.candidate(&app.session, &origin);
assert!(sel.is_some(), "the To input takes the origin point");
d.inputs[2].items.clear();
d.pick(&app.session, sel.unwrap());
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert_eq!(p["to"], json!("origin"));
}
#[test]
fn surface_dialogs_make_their_commands() {
// A planar patch of a sketch's rectangle, then thickened; Edit Feature brings both back.