diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index bcc090e7..72393120 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.align", "Align", align) .at("SOLID", "MODIFY") .icon("move") - .params("bodies: [names]; from: [x,y,z] | from_face: [x,y,z]; to: [x,y,z] | to_face: [x,y,z] (faces also turn to meet); flip?: bool"), + .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"), CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"), ]; @@ -202,6 +202,12 @@ fn align(s: &mut Session, p: &Value) -> Result { 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))); } + // 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]"))) }; let (from, from_normal) = pick(s, "from", "from_face")?; @@ -230,3 +236,38 @@ fn remove(s: &mut Session, p: &Value) -> Result { #[cfg(test)] #[path = "features_more_tests.rs"] mod tests; + +/// A snap point near `at` on the model: the nearest vertex, edge midpoint, planar face's centre +/// or circular edge's centre (the end of a cylindrical face). +pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option { + let st = s.world_state(); + let near = |pts: Vec| pts.into_iter().min_by(|a, b| a.dist(at).total_cmp(&b.dist(at))); + match kind { + "vertex" => near( + st.bodies + .iter() + .flat_map(|b| b.mesh().edges.iter().flat_map(|e| [e.first().copied(), e.last().copied()]).flatten().collect::>()) + .collect(), + ), + "edge_mid" => { + let d = |e: &solvecraft_doc::kernel::EdgeInfo| e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min); + st.bodies + .iter() + .filter_map(|b| b.body.edges((b.body.size() * 2e-3).max(1e-3)).ok()) + .flatten() + .min_by(|a, b| d(a).total_cmp(&d(b))) + .map(|e| e.mid) + } + "face_center" => st.bodies.iter().find_map(|b| { + let i = solvecraft_doc::appearance::face_index_at(b, at)?; + b.body.faces((b.body.size() * 2e-3).max(1e-3)).ok()?.into_iter().find(|f| f.index == i).map(|f| f.centroid) + }), + "circle_center" => st.bodies.iter().find_map(|b| { + let c = solvecraft_doc::kernel::cylinder_face_at(&b.body, at)?; + let along = (at - c.axis_point).dot(c.axis); + let end = if (along - c.start).abs() <= (c.end - along).abs() { c.start } else { c.end }; + Some(c.axis_point + c.axis * end) + }), + _ => None, + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4375be40..4f72b1b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1789,3 +1789,22 @@ fn combine_keep_tools_and_new_component() { assert_eq!(s.doc.body_component("A", a.feature), comp); assert!(rel(solvecraft_kernel::measure(&a.body).unwrap().volume, 4000.0) < 1e-9); } + +/// Align with snap points: a box's vertex onto another's face centre, an edge midpoint onto a +/// hole's circle centre. +#[test] +fn align_with_snap_points() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "body_name": "Base"})); + run(&mut s, "solid.box", json!({"corner": [100, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Cube"})); + // Cube's corner (100, 0, 0) onto Base's top face centre (20, 20, 10). + run( + &mut s, + "solid.align", + json!({"bodies": ["Cube"], "from": {"snap": "vertex", "at": [100.4, 0.3, 0.2]}, "to": {"snap": "face_center", "at": [5, 5, 10]}}), + ); + let m = run(&mut s, "inspect.measure", json!({"bodies": ["Cube"]})); + let min = |i: usize| m["bodies"][0]["bbox"]["min"][i].as_f64().unwrap_or(f64::NAN); + assert!((min(0) - 20.0).abs() < 1e-6 && (min(1) - 20.0).abs() < 1e-6 && (min(2) - 10.0).abs() < 1e-6, "{m}"); + assert!(s.execute("solid.align", &json!({"bodies": ["Cube"], "from": {"snap": "nope", "at": [0, 0, 0]}, "to": [0, 0, 0]})).is_err()); +}