diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 20aaa7cf..fd2f7153 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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)>, }, 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, 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, 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) -> 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::() { + 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 { 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) -> Option) -> Option) -> Option { 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) -> Option, 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}"); +}