mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 13:01:52 +00:00
Align takes planes and the origin (#4248)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user