Drive Joints says where and why a drive stopped short (#1169)

Drive Joints says where and why a drive stopped short.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-14 17:56:21 -04:00
1 parent 5744b1f327
commit 01b1b70586
12 files changed
+165 -9

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "フィーチャーフォルダの名前を変更",
+2
View File
@@ -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": "외관 목록",
+2
View File
@@ -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": "Переименовать папку операций",
+2
View File
@@ -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": "重命名特征文件夹",
+16 -6
View File
@@ -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(())
+36
View File
@@ -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<String> {
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<String> = 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.
@@ -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}");
}
+21 -2
View File
@@ -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<serde_json::Value>,
}
type Job = Result<Built, String>;
@@ -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<serde_json::Value>,
/// 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,
}
}
+4 -1
View File
@@ -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
+6
View File
@@ -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