Joint dialog: say why picks on one component make no joint (#1367)

The Joint and As-built Joint dialogs explain why picks make no joint and offer Create Components from Bodies when every pick is on one component; joint.create and joint.as_built suggest component.from_bodies. Labels in all catalogs. Fixes #1239.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 08:22:54 -04:00
1 parent 6fbb1c5d91
commit 4674f0a61c
14 files changed
+174 -10

No files matched your search

+51
View File
@@ -450,6 +450,7 @@ fn shifted(s: &SnapPt, x: f64, y: f64) -> Value {
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [SelInput], active: &mut usize) -> bool {
let t = Tokens::get();
let mut enter = false;
let problem = joint_problem(app, k, inputs);
match k {
Asm::NewComponent { name, activate } => {
row_label(ui, "Name");
@@ -465,6 +466,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
if f.as_built && *active == 0 && inputs.first().is_some_and(|i| i.items.len() >= 2) {
*active = 1;
}
// Picks that make no joint say why, under them, and offer the way out (#1239).
if let Some((why, bodies)) = problem {
row_label(ui, "");
ui.vertical(|ui| {
ui.add(egui::Label::new(RichText::new(why).color(t.warning)).wrap());
if !bodies.is_empty()
&& ui.button(crate::i18n::tr("cmd.component_from_bodies")).on_hover_text(crate::i18n::tr("joint.from_bodies_hint")).clicked()
{
// A refusal shows on the status line; the picks stay either way.
let _ = app.run("component.from_bodies", json!({ "bodies": bodies }));
}
});
ui.end_row();
}
row_label(ui, "Motion");
ui.label("");
ui.end_row();
@@ -729,6 +744,42 @@ fn sel_items(inputs: &[SelInput], i: usize) -> &[Sel] {
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
}
/// The body a pick is on.
fn body_of(sel: &Sel) -> Option<&str> {
match sel {
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body),
Sel::Body { name } => Some(name),
_ => None,
}
}
/// Why the Joint dialog's picks, all in, make no joint: without it the preview and OK just go
/// quiet, and no option seems to do anything (#1239). With it, the bodies Create Components from
/// Bodies would part (none when that would not help). The usual case is every pick on one
/// component: a STEP file with several parts often brings them as bodies of one component.
fn joint_problem(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Option<(String, Vec<String>)> {
let Asm::Joint(f) = k else { return None };
let picks: Vec<&Sel> =
if f.as_built { sel_items(inputs, 0).iter().collect() } else { (0..2).filter_map(|i| sel_items(inputs, i).first()).collect() };
if f.editing.is_some() || picks.len() < 2 {
return None;
}
let mut bodies: Vec<String> = Vec::new();
for b in picks.iter().filter_map(|p| body_of(p)) {
if !bodies.iter().any(|x| x == b) {
bodies.push(b.to_string());
}
}
let occs: Vec<Option<u64>> = bodies.iter().map(|b| occurrence_of_body(&app.session, b)).collect();
if occs.first().is_some_and(|first| first.is_some() && occs.iter().all(|o| o == first)) {
return Some(match bodies.as_slice() {
[body] => (crate::i18n::format("joint.one_body", &[("body", body.as_str())]), Vec::new()),
_ => (crate::i18n::format("joint.one_component", &[("bodies", &bodies.join(", "))]), bodies),
});
}
commands(app, k, inputs).err().map(|e| (e, Vec::new()))
}
/// The occurrences of picked bodies (deduplicated, in pick order).
fn occurrences(s: &Session, items: &[Sel]) -> Vec<u64> {
let mut out = Vec::new();
@@ -207,6 +207,66 @@ fn ok_click_survives_a_busy_preview() {
assert!(app.dialog.is_none());
}
/// #1239: two parts as bodies of one component (a multi-body STEP import). Picks on both used to
/// make no joint with nothing said, so no option seemed to do anything. The dialog now says why
/// under the picks and offers Create Components from Bodies, after which the same picks join.
#[test]
fn picks_on_one_component_say_why_and_offer_components() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10})).unwrap();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10, "corner": [30, 0, 0]})).unwrap();
let mut app = SolveApp::new(s, Services::default());
let bodies = vec!["Body1".to_string(), "Body2".to_string()];
// As-built: two bodies of one component are one component.
app.session.selection = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }];
app.start("joint.as_built");
let d = app.dialog.take().unwrap();
let Kind::Assembly(k) = &d.kind else { panic!("{:?}", d.kind) };
assert_eq!(joint_problem(&app, k, &d.inputs).map(|p| p.1), Some(bodies.clone()));
// Two faces of one body: pick another part (nothing to part).
app.session.selection.clear();
app.start("joint.create");
let mut d = app.dialog.take().unwrap();
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap());
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 0.0)).unwrap());
let Kind::Assembly(k) = &d.kind else { panic!("{:?}", d.kind) };
assert_eq!(joint_problem(&app, k, &d.inputs).map(|p| p.1), Some(vec![]));
// A face of each body.
app.start("joint.create");
let mut d = app.dialog.take().unwrap();
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap());
d.pick(&app.session, face_sel(&app.session, Vec3::new(40.0, 10.0, 0.0)).unwrap());
assert!(apply_commands(&app, &d).is_err(), "one component: no joint");
app.dialog = Some(d);
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let frame = |app: &mut SolveApp, events: Vec<egui::Event>| {
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()));
// Nothing is painted: drop the texture changes (an unapplied delta panics on drop).
out.textures_delta.clear();
out
};
// The auto-sized dialog takes a few frames to settle its width.
for _ in 0..3 {
frame(&mut app, vec![]);
}
let out = frame(&mut app, vec![]);
let why = crate::i18n::format("joint.one_component", &[("bodies", "Body1, Body2")]);
assert!(text_at(&out, &why).is_some(), "the dialog says why");
let at = text_at(&out, crate::i18n::tr("cmd.component_from_bodies")).expect("and offers the way out");
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
frame(&mut app, vec![button(true)]);
frame(&mut app, vec![button(false)]);
assert!(bodies.iter().all(|b| app.session.doc.find_component(b).is_some()), "each body has a component");
let d = app.dialog.as_ref().expect("the dialog stays open");
let c = apply_commands(&app, d).unwrap();
assert_eq!((c[0].1["a"]["occurrence"].clone(), c[0].1["b"]["occurrence"].clone()), (json!(occ(&app, "Body1")), json!(occ(&app, "Body2"))));
let out = frame(&mut app, vec![]);
assert!(text_at(&out, &why).is_none(), "nothing left to say");
}
/// Two top faces mate face to face as in Fusion: B ends up upside down on A, not inside it;
/// Flip turns it back over (into A).
#[test]