From 4674f0a61c0721c01e00f9cb0127acc3da87a3f8 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:22:54 +0200 Subject: [PATCH] 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. --- assets/locales/de.json | 5 +- assets/locales/en.json | 5 +- assets/locales/fr.json | 5 +- assets/locales/ja.json | 5 +- assets/locales/ko.json | 5 +- assets/locales/ru.json | 5 +- assets/locales/uk.json | 5 +- assets/locales/zh.json | 5 +- crates/engine/src/cmd/joints.rs | 8 ++- crates/engine/src/cmd/joints_tests.rs | 16 ++++++ crates/ui-egui/src/dialogs_assembly.rs | 51 +++++++++++++++++ crates/ui-egui/src/dialogs_assembly_tests.rs | 60 ++++++++++++++++++++ docs/book/src/guide/assemblies.md | 7 +++ skills/solidcraft-mechanisms/reference.md | 2 +- 14 files changed, 174 insertions(+), 10 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index 76960a33..5fd806e1 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -903,5 +903,8 @@ "Mitre": "Gehrung", "Reference face": "Referenzfläche", "Round": "Rund", - "Type": "Typ" + "Type": "Typ", + "joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.", + "joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.", + "joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist." } diff --git a/assets/locales/en.json b/assets/locales/en.json index f8810357..a04d8c93 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1160,5 +1160,8 @@ "RULED SURFACE": "RULED SURFACE", "Mitre": "Mitre", "Reference face": "Reference face", - "Round": "Round" + "Round": "Round", + "joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.", + "joint.one_body": "Both picks are on {body}. Pick the second one on another part.", + "joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is." } diff --git a/assets/locales/fr.json b/assets/locales/fr.json index a2decd8c..25715af4 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -1036,5 +1036,8 @@ "update.install_failed": "La mise à jour ne s'est pas installée : {error}. Rien n'a été modifié ; Télécharger récupère plutôt le paquet.", "cmd.render_settings_get": "Réglages de rendu", "cmd.render_settings_set": "Enregistrer les réglages de rendu", - "drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé" + "drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé", + "joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.", + "joint.one_body": "Les deux sélections sont sur {body}. Choisissez la seconde sur une autre pièce.", + "joint.from_bodies_hint": "Chaque corps sélectionné passe dans un nouveau composant à lui et reste où il est." } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index d5b04f5b..901c485d 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1160,5 +1160,8 @@ "RULED SURFACE": "ルールドサーフェス", "Mitre": "留め継ぎ", "Reference face": "参照面", - "Round": "丸め" + "Round": "丸め", + "joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。", + "joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。", + "joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index f0e72e3f..15f3f1cc 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -903,5 +903,8 @@ "Mitre": "연귀", "Reference face": "참조 면", "Round": "둥글게", - "Type": "유형" + "Type": "유형", + "joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.", + "joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.", + "joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다." } diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 1cb1c169..9736c97f 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -1073,5 +1073,8 @@ "appearance.dielectric_ior": "Показатель преломления диэлектрика", "appearance.pbr_help": "Просмотр поверхности во вкладке Рендер или на PNG изделия. Без преломления и пропускания света.", "render.opaque_preview_help": "Непрозрачный PBR и простые цвета; прозрачность через смешивание. Преломление, HDRI и финальный рендер пока недоступны.", - "render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание" + "render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание", + "joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.", + "joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.", + "joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте." } diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 9c572138..8e799cf0 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -1019,5 +1019,8 @@ "update.installer_started": "Інсталятор запуститься, коли SolidCraft закриється.", "update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть SolidCraft до Applications, замінивши стару копію, а потім перезапустіть SolidCraft.", "update.install_failed": "Оновлення не встановлено: {error}. Нічого не змінено; натомість «Завантажити» отримає пакет.", - "drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим" + "drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим", + "joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.", + "joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.", + "joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці." } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 478f2171..bb32bcc7 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1160,5 +1160,8 @@ "RULED SURFACE": "直纹曲面", "Mitre": "斜接", "Reference face": "参考面", - "Round": "圆角" + "Round": "圆角", + "joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。", + "joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。", + "joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。" } diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 2477e87e..313f681f 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -318,8 +318,14 @@ fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result { if s.doc.assembly.joints.len() >= 10_000 { return Err(EngineError::Other("too many joints".into())); } + // Several parts as bodies of one component (a multi-body STEP import, #1239): say how to + // get two components. if j.a.occurrence == j.b.occurrence { - return Err(bad("joint.create", "a joint needs two different occurrences")); + return Err(bad( + "joint.create", + "a joint needs two different occurrences: both origins are on one component; give each part a component of its own first \ + (component.from_bodies)", + )); } let id = j.id; let name = j.name.clone(); diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 00c4dc53..8764b8b6 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -247,6 +247,22 @@ fn faces_mate_by_default() { assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0))); } +/// #1239: two parts as bodies of one component (a multi-body STEP import). The refusal names the +/// way out, and after Create Components from Bodies the same origins make the joint. +#[test] +fn bodies_of_one_component_get_components_first() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]})); + let p = json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}}); + let e = s.execute("joint.create", &p).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains("two different occurrences") && e.contains("component.from_bodies"), "{e}"); + run(&mut s, "component.from_bodies", json!({"bodies": ["Body1", "Body2"]})); + run(&mut s, "joint.create", p); + // The first joint's A side stays; B lands on top of it. + assert!(near(place(&s, "Body2", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0))); +} + /// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still /// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way /// into the first: exactly that pair overlaps, by 0.5 mm³. diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 738975dc..5afce03d 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -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)> { + 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 = 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> = 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 { let mut out = Vec::new(); diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index b5b7fe7e..7ee32d2f 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -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| { + 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] diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 407eaf8d..67d3b9b1 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -28,6 +28,13 @@ made. Contact sets stop motion where bodies would pass through each other, **Motion Study** keys joint values along a timeline, and **Interference** shows overlapping volumes in red. +A joint moves one component against another, so its two picks must be on different components. +A STEP file with several parts often comes in as bodies of one component, and picks on two of +those bodies make no joint. The Joint and As-built Joint dialogs say so under the picks and offer +**Create Components from Bodies**: each picked body gets a component of its own and stays where it +is, the picks are kept, and the joint previews. `joint.create` refuses such origins with the same +advice. + Joints may close a loop, as in a four-bar linkage or a slider-crank. When one joint of the loop is driven, the other joints in the loop follow so that every joint still meets, and the mechanism stays in the assembly mode it started in (an open four-bar does not flip to the diff --git a/skills/solidcraft-mechanisms/reference.md b/skills/solidcraft-mechanisms/reference.md index a76d0f6f..91fa3185 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -129,7 +129,7 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft | A linked joint stops following | Its value is clamped to its own limits. Hinge 2 in the example has `[[-60, 0]]`, so a drive of Hinge below 0 leaves it at 0. Give a mirrored joint the mirrored limits. | | A drive stops short, `stopped_by_contact` | Two bodies of a contact set touch. `fraction` is how far the move got. | | `occurrence.ground` says there is no occurrence `Frame` | It takes occurrence names only: `Frame:1`. | -| `a joint needs two different occurrences` | Both origins found the same part. Give `occurrence` on both. | +| `a joint needs two different occurrences` | Both origins found the same part. Give `occurrence` on both. If the parts are bodies of one component (an imported STEP file often brings them so), run `component.from_bodies {"bodies": [...]}` first. | | "unknown command" for `file.open` | Open a design with the `open` tool or `doc.open {path}`. | | Rigid hoses, cables or springs drift as the mechanism moves | Bodies don't bend. Hide them with `browser.visibility`, or model them per link. | | `solidcraft-cli snapshot SCRIPT --in design` runs on an empty design | `snapshot` ignores `--in`. Run `solidcraft-cli run SCRIPT --in design --save out.solidcraft` first. |