From 01b1b705864e056fb19c8273522d2dcf3c63010a Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:56:21 +0200 Subject: [PATCH] Drive Joints says where and why a drive stopped short (#1169) Drive Joints says where and why a drive stopped short. --- assets/locales/de.json | 2 + assets/locales/en.json | 2 + assets/locales/ja.json | 2 + assets/locales/ko.json | 2 + assets/locales/ru.json | 2 + assets/locales/zh.json | 2 + crates/ui-egui/src/dialogs.rs | 22 ++++-- crates/ui-egui/src/dialogs_assembly.rs | 36 ++++++++++ crates/ui-egui/src/dialogs_assembly_tests.rs | 70 ++++++++++++++++++++ crates/ui-egui/src/preview.rs | 23 ++++++- docs/book/src/guide/assemblies.md | 5 +- docs/contact-sets.md | 6 ++ 12 files changed, 165 insertions(+), 9 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index 11fad25c..3cf101e3 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -404,6 +404,8 @@ "ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren", "ui.zoom": "Zoomen", "ui.command_done": "{command} fertig", + "drive.stopped_by_loop": "Angehalten bei {value}: die Schleife über {joint} lässt sich nicht weiter schließen", + "drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen", "cmd.appearance_assign": "Erscheinungsbild", "cmd.appearance_library": "Erscheinungsbild-Bibliothek", "cmd.appearance_list": "Erscheinungsbilder auflisten", diff --git a/assets/locales/en.json b/assets/locales/en.json index 01b8af7a..ab15d6aa 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -957,6 +957,8 @@ "ui.updateToCurrentView": "Update to Current View", "ui.zoom": "Zoom", "ui.command_done": "{command} done", + "drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further", + "drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}", "timeline.group": "New Feature Folder", "timeline.ungroup": "Remove Feature Folders", "timeline.rename_group": "Rename Feature Folder", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 2a80e03c..5fa01f16 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -957,6 +957,8 @@ "ui.updateToCurrentView": "現在のビューに更新", "ui.zoom": "ズーム", "ui.command_done": "{command} 完了", + "drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません", + "drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます", "timeline.group": "フィーチャーフォルダを新規作成", "timeline.ungroup": "フィーチャーフォルダを削除", "timeline.rename_group": "フィーチャーフォルダの名前を変更", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 78fde786..e675c7f3 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -404,6 +404,8 @@ "ui.updateToCurrentView": "현재 보기로 업데이트", "ui.zoom": "확대/축소", "ui.command_done": "{command} 완료", + "drive.stopped_by_loop": "{value}에서 멈춤: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다", + "drive.stopped_by_contact": "{value}에서 멈춤: {a}이(가) {b}을(를) 통과하게 됩니다", "cmd.appearance_assign": "외관", "cmd.appearance_library": "외관 라이브러리", "cmd.appearance_list": "외관 목록", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 033fbcb4..19999f8f 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -949,6 +949,8 @@ "ui.updateToCurrentView": "Обновить до текущего вида", "ui.zoom": "Масштаб", "ui.command_done": "{command} — готово", + "drive.stopped_by_loop": "Остановлено на {value}: контур через {joint} дальше не замыкается", + "drive.stopped_by_contact": "Остановлено на {value}: {a} прошёл бы сквозь {b}", "timeline.group": "Новая папка операций", "timeline.ungroup": "Убрать папки операций", "timeline.rename_group": "Переименовать папку операций", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 0a841635..a6936c56 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -955,6 +955,8 @@ "ui.updateToCurrentView": "更新为当前视图", "ui.zoom": "缩放", "ui.command_done": "{command} 完成", + "drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合", + "drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}", "timeline.group": "新建特征文件夹", "timeline.ungroup": "移除特征文件夹", "timeline.rename_group": "重命名特征文件夹", diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b4d7a6ba..d0fdcc1b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3030,7 +3030,7 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] { d.inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) } -fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { +pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { let commands = apply_commands(app, d)?; // The live preview may be computing these very commands: OK takes its result. crate::preview::settle(app, &commands); @@ -3039,12 +3039,22 @@ fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { roll_marker(app, marker)?; } for (cmd, params) in commands { - if let Err(error) = app.run(&cmd, params) { - if let Some((id, _)) = d.editing { - let position = app.session.doc.feature_index(id); - let _ = roll_marker(app, position); + match app.run(&cmd, params) { + Ok(reply) => { + // A drive that stopped short says where, and why. + if cmd == "joint.drive" + && let Some(why) = crate::dialogs_assembly::drive_stop(&app.session, &reply) + { + app.set_status(why, false); + } + } + Err(error) => { + if let Some((id, _)) = d.editing { + let position = app.session.doc.feature_index(id); + let _ = roll_marker(app, position); + } + return Err(error); } - return Err(error); } } Ok(()) diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 76eab174..4ef4a39e 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -548,6 +548,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se ); ui.end_row(); } + // The preview drove the joint: where it stopped short, and why (a closed loop or a + // contact set), so the values asked for are not taken for where it is. + if let Some(why) = app.preview.stopped.as_ref().and_then(|r| drive_stop(&app.session, r)) { + row_label(ui, ""); + ui.label(RichText::new(why).color(t.warning)); + ui.end_row(); + } } Asm::MotionLink { a, b, ia, ib, ratio, offset } => { let s = &app.session; @@ -834,6 +841,11 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O Ok(preview) => preview, Err(error) => return Some(Err(error.to_string())), }; + let stopped = cmds + .iter() + .zip(&preview.replies) + .find(|((c, _), r)| c == "joint.drive" && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object())) + .map(|(_, r)| r.clone()); let (before, after) = (preview.before, preview.after); // A moved component looks like itself (no new surface), with its old place as the ghost. let colors = Colors { added: colors.body, ..colors }; @@ -841,12 +853,36 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O Some(match built { Ok(mut b) => { b.bounds = after.bodies.iter().fold(Aabb3::EMPTY, |acc, x| acc.union(&x.mesh().bounds())); + b.stopped = stopped; Ok(b) } Err(_) => Err("the preview failed".into()), }) } +/// What a drive that stopped short of the asked values says: where the joint got to and why +/// (`None`: it went all the way). `reply` is `joint.drive`'s result. +pub fn drive_stop(s: &Session, reply: &Value) -> Option { + let id = reply.get("joint").and_then(Value::as_u64)?; + let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?; + let at: Vec = reply + .get("values") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .filter_map(|(i, v)| v.as_f64().map(|v| (i, v))) + .map(|(i, v)| if j.kind.is_angle(i) { num((v.to_degrees() * 10.0).round() / 10.0, "deg") } else { num((v * 100.0).round() / 100.0, "mm") }) + .collect(); + let at = at.join(", "); + let name = |v: &Value| v.as_str().unwrap_or_default().to_string(); + if let Some(l) = reply.get("stopped_by_loop").filter(|v| v.is_object()) { + return Some(crate::i18n::format("drive.stopped_by_loop", &[("value", &at), ("joint", &name(&l["joint"]))])); + } + let c = reply.get("stopped_by_contact").filter(|v| v.is_object())?; + Some(crate::i18n::format("drive.stopped_by_contact", &[("value", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))])) +} + // ---- overlay ---- /// Snap glyphs under the cursor and at the picked origins; the overlap of an interference. diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 95b45b86..2a27c25f 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -364,3 +364,73 @@ fn edit_feature_on_a_moved_component_refills_in_the_world() { assert_eq!(body, "Body2"); assert!(point.dist(Vec3::new(40.0, 0.0, 25.0)) < 1e-6, "{point:?}"); } + +/// A crank-rocker four-bar (crank 20, coupler 50, rocker 40, ground pivots 45 apart), built as +/// it stands with the crank at 90°: joints Crank, Crank pin, Rocker pin, Rocker. +fn four_bar() -> SolveApp { + let mut s = Session::default(); + let (p0, o4) = ([0.0, 20.0], [45.0, 0.0]); + // The rocker pin: where the coupler (50 from the crank pin) meets the rocker (40 from O4). + let (dx, dy) = (o4[0] - p0[0], o4[1] - p0[1]); + let l = f64::hypot(dx, dy); + let a = (2500.0 - 1600.0 + l * l) / (2.0 * l); + let h = (2500.0 - a * a).sqrt(); + let q0 = [p0[0] + dx * a / l - dy * h / l, p0[1] + dy * a / l + dx * h / l]; + for (name, at) in [("Ground", [0.0, 0.0]), ("Crank", p0), ("Coupler", q0), ("Rocker", o4)] { + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + s.execute("component.create", &json!({ "name": name })).unwrap(); + s.execute("solid.box", &json!({"length": 2, "width": 2, "height": 2, "corner": [at[0], at[1], -5]})).unwrap(); + } + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + s.execute("occurrence.ground", &json!({"occurrence": "Ground:1", "grounded": true})).unwrap(); + for (name, a, b, p) in [ + ("Crank", "Ground", "Crank", [0.0, 0.0]), + ("Crank pin", "Crank", "Coupler", p0), + ("Rocker pin", "Coupler", "Rocker", q0), + ("Rocker", "Ground", "Rocker", o4), + ] { + let at = |o: &str| json!({"occurrence": o, "point": [p[0], p[1], 0], "z": [0, 0, 1]}); + s.execute("joint.create", &json!({"type": "revolute", "a": at(a), "b": at(b), "flip": true, "name": name})).unwrap(); + } + SolveApp::new(s, Services::default()) +} + +/// Drive Joints says where a drive stopped short and why: the preview names the loop that can't +/// close further, OK says it on the status line, and a drive within reach says nothing. +#[test] +fn drive_says_where_a_loop_stopped_it() { + let mut app = four_bar(); + let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let rocker = app.session.doc.assembly.joints.iter().position(|j| j.name == "Rocker").unwrap(); + let mut d = drive_joint(&app, app.session.doc.assembly.joints[rocker].id).unwrap(); + let ask = |d: &mut Dialog, v: &str| { + if let Kind::Assembly(Asm::Drive { values, .. }) = &mut d.kind { + values[0] = v.into(); + } + }; + // Within its swing: no stop. + ask(&mut d, "20 deg"); + let b = world_preview(&app.session, &apply_commands(&app, &d).unwrap(), colors).unwrap().unwrap(); + assert!(b.stopped.is_none()); + // Past its toggle position (about +50.4° from where it stands). + ask(&mut d, "100 deg"); + let b = world_preview(&app.session, &apply_commands(&app, &d).unwrap(), colors).unwrap().unwrap(); + let why = b.stopped.as_ref().and_then(|r| drive_stop(&app.session, r)).unwrap(); + assert!(why.starts_with("Stopped at 50.") && why.contains("can't close any further"), "{why}"); + // OK drives it there and says so. + crate::dialogs::run_dialog(&mut app, &d).unwrap(); + let (status, _, error) = app.status.clone().unwrap(); + assert!(status == why && !error, "{status}"); +} + +/// A contact set stops a drive too: the message names the parts that would pass through each other. +#[test] +fn drive_says_which_parts_a_contact_set_stopped() { + let mut app = two_boxes(); + let at = |o: &str| json!({"occurrence": o, "point": [30, 10, 5], "z": [1, 0, 0]}); + app.run("joint.create", json!({"type": "slider", "a": at("A"), "b": at("B"), "flip": true, "name": "Rail"})).unwrap(); + app.run("contact.create", json!({"occurrences": ["A:1", "B:1"]})).unwrap(); + let r = app.run("joint.drive", json!({"joint": "Rail", "value": -20})).unwrap(); + let why = drive_stop(&app.session, &r).unwrap(); + assert!(why.starts_with("Stopped at -1") && why.contains("A:1") && why.contains("B:1") && why.contains("would pass through"), "{why}"); +} diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index a94baadb..b59de0db 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -53,6 +53,9 @@ pub struct Built { pub applied: Option<(f64, f64)>, /// The radius spline of each edge a fillet tapered (`fit: taper`), with a point on the edge. pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>, + /// The reply of a previewed drive that stopped short of the asked values (the dialog says + /// where and why, in the UI's language). + pub stopped: Option, } type Job = Result; @@ -90,6 +93,8 @@ pub struct PreviewState { started: f64, /// The smallest and largest radius the previewed fillet used when it reduced the one asked for. pub applied: Option<(f64, f64)>, + /// The reply of a previewed drive that stopped short (see [`Built::stopped`]). + pub stopped: Option, /// The radius splines the previewed fillet tapered its edges with. pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>, } @@ -101,6 +106,7 @@ impl PreviewState { self.shown_key = None; self.error = None; self.applied = None; + self.stopped = None; self.tapered.clear(); self.sketches.clear(); self.busy = false; @@ -130,6 +136,7 @@ impl PreviewState { self.active = true; self.error = b.error; self.applied = b.applied; + self.stopped = b.stopped; self.tapered = b.tapered; self.set_scene(b.scene); } @@ -137,6 +144,7 @@ impl PreviewState { self.sketches.clear(); self.error = Some(e); self.applied = None; + self.stopped = None; self.tapered.clear(); } } @@ -338,6 +346,7 @@ fn compute_staged(s: &Session, cmds: &[(String, Value)], colors: Colors, partial bounds: b.bounds, applied: None, tapered: Vec::new(), + stopped: None, }); b.error = s.preview(cmds).err().map(|e| e.to_string()); Ok(b) @@ -387,7 +396,16 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B } } } - Built { scene: sc, sketches: Vec::new(), replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() } + Built { + scene: sc, + sketches: Vec::new(), + replaced: Vec::new(), + error: None, + bounds: Aabb3::EMPTY, + applied: None, + tapered: Vec::new(), + stopped: None, + } } /// The same commands making a new body instead of joining, cutting or intersecting, and the @@ -524,7 +542,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) } }) .collect(); - Built { scene: sc, sketches, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() } + Built { scene: sc, sketches, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new(), stopped: None } } /// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by @@ -719,6 +737,7 @@ mod tests { bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new(), + stopped: None, } } diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 72f0b990..667efad7 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -34,7 +34,10 @@ mechanism stays in the assembly mode it started in (an open four-bar does not fl crossed one). If the motion would take a loop past the point where it can still close, for example driving a rocker beyond its swing, the motion stops at the last position where the loop closes, and the drive reports `stopped_by_loop`. A loop that cannot close at all is listed -under `conflicts` and nothing in it moves. +under `conflicts` and nothing in it moves. The Drive Joints dialog says when a drive stops short and +why. While it previews, a row under the values gives the value the joint reached and the reason +(the loop that can't close further, or the two parts a contact set keeps apart), and after OK the +status line repeats it. The value box keeps what you typed. **Insert Design** (`component.insert_design {path, translate?}`) adds a component linked to another saved design, so each part can live in its own document. The part's solid bodies come diff --git a/docs/contact-sets.md b/docs/contact-sets.md index bc240e27..4cc718a0 100644 --- a/docs/contact-sets.md +++ b/docs/contact-sets.md @@ -16,6 +16,12 @@ to leave the bodies just short of penetration, within the tessellated contact to Motion links and limits participate in the trajectory checks. Moving a body between components preserves its placement for contact checks through its body offset. +A drive can also stop where a closed loop of joints (a four-bar, a slider-crank) can no longer +close; it then returns `stopped_by_loop` instead, with the joint whose loop opens and the +fraction reached. When both apply, the motion stops at whichever comes first, and only that +one is reported. The Drive Joints dialog shows the reached value and the reason while it +previews, and the status line repeats it after OK. + The trajectory uses bounded samples of cached tessellated geometry. Spacing accounts for effective joint travel, angular motion, distance from joint pivots and whole-body widths along sampled face directions. This handles tilted thin plates while keeping curved bodies