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:
1 parent
6fbb1c5d91
commit
4674f0a61c
14 files changed
+174
-10
No files matched your search
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。"
|
||||
}
|
||||
@@ -903,5 +903,8 @@
|
||||
"Mitre": "연귀",
|
||||
"Reference face": "참조 면",
|
||||
"Round": "둥글게",
|
||||
"Type": "유형"
|
||||
"Type": "유형",
|
||||
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
|
||||
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
|
||||
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다."
|
||||
}
|
||||
@@ -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": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте."
|
||||
}
|
||||
@@ -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": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці."
|
||||
}
|
||||
@@ -1160,5 +1160,8 @@
|
||||
"RULED SURFACE": "直纹曲面",
|
||||
"Mitre": "斜接",
|
||||
"Reference face": "参考面",
|
||||
"Round": "圆角"
|
||||
"Round": "圆角",
|
||||
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
|
||||
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
|
||||
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。"
|
||||
}
|
||||
@@ -318,8 +318,14 @@ fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
|
||||
@@ -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³.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user