fix(ui): primitives go on the picked face or vertex (#580) (#598)

Box, Cylinder, Sphere, Torus and Coil go where a face or vertex was picked before starting them: centred on the point, standing on a planar face (a box on an axis-aligned face sits on it; cylinders and coils take the face normal as axis). The dialog shows the position with a button to place at the origin instead. Fixes #580.

Co-authored-by: lasselian <[email protected]>
This commit is contained in:
Lasse Lianandlasselian authored and GitHub committed 2026-10-13 05:14:35 -04:00
1 parent 04cfb74c5e
commit bf962a9989
2 files changed
+158 -7

No files matched your search

+75 -7
View File
@@ -204,6 +204,10 @@ pub enum Kind {
cmd: &'static str,
fields: Vec<(&'static str, String)>,
operation: usize,
/// Where it goes: a picked point on a face or vertex, with the face's normal when the
/// face is planar. `None` keeps the command's own placement (the origin, or the edited
/// feature's).
at: Option<(Vec3, Option<Vec3>)>,
},
Combine {
operation: usize,
@@ -438,22 +442,27 @@ impl Dialog {
cmd: "solid.box",
fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())],
operation: 0,
at: None,
},
vec![],
),
"solid.cylinder" => Dialog::new(
Kind::Primitive { cmd: "solid.cylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 },
Kind::Primitive { cmd: "solid.cylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0, at: None },
vec![],
),
"solid.sphere" => Dialog::new(Kind::Primitive { cmd: "solid.sphere", fields: vec![("diameter", "20".into())], operation: 0 }, vec![]),
"solid.torus" => {
Dialog::new(Kind::Primitive { cmd: "solid.torus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 }, vec![])
"solid.sphere" => {
Dialog::new(Kind::Primitive { cmd: "solid.sphere", fields: vec![("diameter", "20".into())], operation: 0, at: None }, vec![])
}
"solid.torus" => Dialog::new(
Kind::Primitive { cmd: "solid.torus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0, at: None },
vec![],
),
"solid.coil" => Dialog::new(
Kind::Primitive {
cmd: "solid.coil",
fields: vec![("diameter", "40".into()), ("revolutions", "4".into()), ("pitch", "8".into()), ("section_size", "3".into())],
operation: 0,
at: None,
},
vec![],
),
@@ -531,6 +540,14 @@ impl Dialog {
}
}
}
// A primitive goes where a face or vertex is picked, standing on a planar face.
if let Kind::Primitive { at, .. } = &mut d.kind {
*at = s.selection.iter().find_map(|sel| match sel {
Sel::Face { body, index, point } => Some((*point, planar_face(s, body, *index).map(|(_, n)| n))),
Sel::Vertex { point, .. } => Some((*point, None)),
_ => None,
});
}
// With nothing pre-selected, a single profile in the sketch being used is taken.
if matches!(d.kind, Kind::Extrude { .. } | Kind::Revolve { .. })
&& let Some(inp) = d.inputs.first_mut()
@@ -1928,12 +1945,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row();
}
}
Kind::Primitive { fields, operation, .. } => {
Kind::Primitive { fields, operation, at, .. } => {
for (k, v) in fields.iter_mut() {
row_label(ui, k);
enter |= field(ui, v);
ui.end_row();
}
if let Some((p, _)) = *at {
row_label(ui, "Position");
ui.horizontal(|ui| {
ui.label(format!("{:.2}, {:.2}, {:.2}", p.x, p.y, p.z));
if ui.small_button("×").on_hover_text("Place at the origin instead").clicked() {
*at = None;
}
});
ui.end_row();
}
row_label(ui, "Operation");
combo(ui, "pr_op", &OP_LABELS, operation);
ui.end_row();
@@ -2931,11 +2958,27 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
return Ok(out);
}
Kind::Primitive { cmd, fields, operation } => {
Kind::Primitive { cmd, fields, operation, at } => {
let mut p = serde_json::Map::new();
for (k, v) in fields {
p.insert((*k).into(), json!(v));
}
if let Some((point, normal)) = *at {
match *cmd {
"solid.box" => {
p.insert("corner".into(), box_corner(s, fields, point, normal));
}
"solid.cylinder" | "solid.coil" => {
p.insert("base".into(), pt(point));
if let Some(n) = normal {
p.insert("axis".into(), pt(n));
}
}
_ => {
p.insert("center".into(), pt(point));
}
}
}
p.insert("operation".into(), json!(OPS.get(*operation).copied().unwrap_or("new")));
(cmd, Value::Object(p))
}
@@ -2979,6 +3022,28 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Ok(vec![(cmd.to_string(), params)])
}
/// The corner of a box placed at `p`: standing on a face whose normal is along an axis (centred
/// on `p` across the face), otherwise centred on `p`. Sizes that are expressions stay
/// expressions, so the box keeps standing on the face when they change.
fn box_corner(s: &Session, fields: &[(&'static str, String)], p: Vec3, normal: Option<Vec3>) -> Value {
let size = |k: &str| fields.iter().find(|f| f.0 == k).map(|f| s.doc.with_design_unit(&f.1, ValueKind::Length)).unwrap_or_default();
let along = |axis: Vec3| normal.map(|n| n.dot(axis)).filter(|d| d.abs() > 1.0 - 1e-9);
let coord = |c: f64, size: String, axis: Vec3| {
// How much of the size lies behind `p`: none (or all) along the face normal, half across it.
let back = match along(axis) {
Some(d) if d > 0.0 => return json!(c),
Some(_) => 1.0,
None => 0.5,
};
match size.trim().parse::<f64>() {
Ok(v) => json!(c - back * v),
Err(_) if back < 1.0 => json!(format!("{c} mm - ({size}) / 2")),
Err(_) => json!(format!("{c} mm - ({size})")),
}
};
json!([coord(p.x, size("length"), Vec3::X), coord(p.y, size("width"), Vec3::Y), coord(p.z, size("height"), Vec3::Z)])
}
/// Sketch curve ids picked in an input.
fn curve_ids(d: &Dialog, i: usize) -> Vec<String> {
sels(d, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect()
@@ -3452,6 +3517,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
cmd: "solid.box",
fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
operation: op_index(operation),
at: None,
};
d.extra.insert("corner".into(), pt_expr(corner));
d
@@ -3462,6 +3528,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
cmd: "solid.cylinder",
fields: vec![("radius", radius.clone()), ("height", height.clone())],
operation: op_index(operation),
at: None,
};
d.extra.insert("base".into(), pt_expr(base));
d.extra.insert("axis".into(), pt3(*axis));
@@ -3469,7 +3536,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
FeatureKind::Sphere { center, radius, operation } => {
let mut d = start("solid.sphere")?;
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) };
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation), at: None };
d.extra.insert("center".into(), pt_expr(center));
d
}
@@ -3479,6 +3546,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
cmd: "solid.torus",
fields: vec![("major", major.clone()), ("minor", minor.clone())],
operation: op_index(operation),
at: None,
};
d.extra.insert("center".into(), pt_expr(center));
d
+83
View File
@@ -754,3 +754,86 @@ fn split_body_by_a_curved_face_sends_it_as_a_tool() {
let p = apply_commands(&app, &d).unwrap().remove(0).1;
assert!(p["tool"]["point"].is_array() && p.get("plane").is_none(), "{p}");
}
/// Runs a primitive's dialog with `sels` pre-selected and returns the new body's centroid.
fn primitive_on(app: &mut SolveApp, sels: Vec<Sel>, id: &str, edit: impl FnOnce(&mut Dialog)) -> (serde_json::Value, Vec3) {
let mut d = start(app, sels, id);
edit(&mut d);
let (cmd, p) = apply_commands(app, &d).unwrap().remove(0);
let name = app.session.execute(&cmd, &p).unwrap()["created_bodies"][0].as_str().unwrap().to_string();
let st = app.session.model.state();
(p, st.body(&name).unwrap().mesh().measure().centroid)
}
/// The face of Body1 whose centre is at `c`, picked at `p`.
fn picked(app: &SolveApp, c: Vec3, p: Vec3) -> Sel {
let Sel::Face { body, index, .. } = face(app, c) else { panic!("a face") };
Sel::Face { body, index, point: p }
}
fn near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 0.05
}
#[test]
fn primitives_go_on_the_picked_face() {
// #580: a sphere (or any primitive) made with a face selected went to the origin.
let mut app = boxed();
let top = picked(&app, Vec3::new(20.0, 15.0, 20.0), Vec3::new(8.0, 10.0, 20.0));
let (p, c) = primitive_on(&mut app, vec![top.clone()], "solid.sphere", |_| {});
assert_eq!(p["center"], json!([8.0, 10.0, 20.0]), "{p}");
assert!(near(c, Vec3::new(8.0, 10.0, 20.0)), "{c:?}");
let (p, c) = primitive_on(&mut app, vec![top.clone()], "solid.cylinder", |_| {});
assert_eq!(p["axis"], json!([0.0, 0.0, 1.0]), "standing on the top face: {p}");
assert!(near(c, Vec3::new(8.0, 10.0, 30.0)), "{c:?}");
// A box stands on the face, centred on the picked point across it.
let (_, c) = primitive_on(&mut app, vec![top.clone()], "solid.box", |_| {});
assert!(near(c, Vec3::new(8.0, 10.0, 30.0)), "{c:?}");
let (p, c) = primitive_on(&mut app, vec![top], "solid.torus", |_| {});
assert_eq!(p["center"], json!([8.0, 10.0, 20.0]), "{p}");
assert!(near(c, Vec3::new(8.0, 10.0, 20.0)), "{c:?}");
}
#[test]
fn a_box_stands_out_of_a_face_that_faces_down_an_axis() {
let mut app = boxed();
// The x = 0 side faces -X: the box goes the other way, away from the body.
let side = picked(&app, Vec3::new(0.0, 15.0, 10.0), Vec3::new(0.0, 12.0, 8.0));
let (_, c) = primitive_on(&mut app, vec![side.clone()], "solid.box", |_| {});
assert!(near(c, Vec3::new(-10.0, 12.0, 8.0)), "{c:?}");
// Sizes that are expressions stay expressions in the corner.
app.session.execute("parameters.add", &json!({"name": "s", "expression": "6 mm"})).unwrap();
let (p, c) = primitive_on(&mut app, vec![side.clone()], "solid.box", |d| {
if let Kind::Primitive { fields, .. } = &mut d.kind {
fields.iter_mut().for_each(|f| f.1 = "s".into());
}
});
assert_eq!(p["corner"][0], json!("0 mm - (s)"), "{p}");
assert!(near(c, Vec3::new(-3.0, 12.0, 8.0)), "{c:?}");
// The cylinder's axis points out of the face.
let (p, c) = primitive_on(&mut app, vec![side], "solid.cylinder", |_| {});
assert_eq!(p["axis"], json!([-1.0, 0.0, 0.0]), "{p}");
assert!(near(c, Vec3::new(-10.0, 12.0, 8.0)), "{c:?}");
}
#[test]
fn a_primitive_without_a_pick_or_with_its_position_cleared_stays_at_the_origin() {
let mut app = boxed();
let (p, c) = primitive_on(&mut app, vec![], "solid.sphere", |_| {});
assert!(p.get("center").is_none(), "{p}");
assert!(near(c, Vec3::ZERO), "{c:?}");
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
let (p, _) = primitive_on(&mut app, vec![top], "solid.sphere", |d| {
if let Kind::Primitive { at, .. } = &mut d.kind {
*at = None;
}
});
assert!(p.get("center").is_none(), "{p}");
// A vertex places it too.
let v = Sel::Vertex { body: "Body1".into(), point: Vec3::new(40.0, 30.0, 20.0) };
let (p, _) = primitive_on(&mut app, vec![v], "solid.sphere", |_| {});
assert_eq!(p["center"], json!([40.0, 30.0, 20.0]), "{p}");
}