Hide and show joints in the browser, like other items (#1168)

Fixes #1160.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-14 15:04:57 -04:00
1 parent e3459edea8
commit 229de1484c
15 files changed
+470 -15

No files matched your search

+4
View File
@@ -57,6 +57,8 @@ pub struct TreeState {
pub picked_components: Vec<u64>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// Joints picked in the tree (a hidden one shows highlighted while it is picked).
pub picked_joints: Vec<u64>,
/// Folders picked in the feature history (features use the engine selection).
pub picked_timeline_groups: Vec<u64>,
/// Last feature-history item clicked, for Shift ranges (`f:<id>` or `g:<id>`).
@@ -429,6 +431,7 @@ fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>
}
} else {
app.tree.picked_components.clear();
app.tree.picked_joints.clear();
let _ = app.run("select.set", json!({ "items": [sel] }));
}
}
@@ -1032,6 +1035,7 @@ fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
if !ui.input(|i| i.modifiers.command || i.modifiers.ctrl) {
app.tree.picked_canvases.clear();
app.tree.picked_components.clear();
app.tree.picked_joints.clear();
let _ = app.run("select.clear", json!({}));
}
if selected {
+15 -1
View File
@@ -550,6 +550,12 @@ fn plane_items(app: &SolveApp, id: u64) -> Vec<Item> {
fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
let Some(j) = app.session.doc.assembly.joints.iter().find(|j| j.id == id) else { return Vec::new() };
let rigid = j.kind == solidcraft_engine::doc::joints::JointKind::Rigid;
// Show/Hide acts on every joint picked in the browser (this one among them).
let mut joints = app.tree.picked_joints.clone();
if !joints.contains(&id) {
joints = vec![id];
}
let hidden = !joints.iter().any(|j| app.session.joint_visible(*j));
vec![
act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })),
act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid),
@@ -560,6 +566,9 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
Item::sep(),
cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })),
act("ui.rename", "Rename", "").with(json!({ "joint": id })),
Item::sep(),
// Hiding a joint hides only its glyph: it still constrains and moves the parts.
act("browser.visibility", "Show/Hide", "eye").key("V").with(json!({ "joints": joints, "visible": hidden })),
]
}
@@ -1094,7 +1103,7 @@ pub fn delete(app: &mut SolveApp) {
/// Show/Hide (the V key): what is selected in the viewport or picked in the browser flips its
/// visibility (bodies, faces' and edges' bodies, sketches, construction and origin planes and
/// axes, components' bodies, canvases). When some are shown and some hidden, all are hidden.
/// axes, components' bodies, canvases, joints' glyphs). When some are shown and some hidden, all are hidden.
pub fn toggle_visibility(app: &mut SolveApp) {
use solidcraft_engine::doc::FeatureKind;
let paths: Vec<Vec<u64>> =
@@ -1124,7 +1133,9 @@ pub fn toggle_visibility(app: &mut SolveApp) {
})
.collect();
let canvases = app.tree.picked_canvases.clone();
let joints = app.tree.picked_joints.clone();
let any_visible = bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b))
|| joints.iter().any(|j| app.session.joint_visible(*j))
|| sketches.iter().any(|s| crate::browser::sketch_visible(app, *s))
|| origin.iter().any(|o| !app.session.visibility.hidden_origin.contains(o))
|| app.session.doc.canvases.iter().any(|c| canvases.contains(&c.id) && c.visible);
@@ -1138,6 +1149,9 @@ pub fn toggle_visibility(app: &mut SolveApp) {
for s in sketches {
crate::browser::set_sketch_visible(app, s, !hide);
}
if !joints.is_empty() {
let _ = app.run("browser.visibility", json!({ "joints": joints, "visible": !hide }));
}
for c in canvases {
let _ = app.run("canvas.edit", json!({ "canvas": c, "visible": !hide }));
}
+2 -1
View File
@@ -264,11 +264,12 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
}
"ui.set" => {
// What is hidden lives in the session (`browser.visibility`); `ui.set` still sets it.
const VIS: [(&str, &str); 4] = [
const VIS: [(&str, &str); 5] = [
("hiddenBodies", "hidden_bodies"),
("hiddenOrigin", "hidden_origin"),
("hiddenSketches", "hidden_sketches"),
("shownSketches", "shown_sketches"),
("hiddenJoints", "hidden_joints"),
];
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
let mut vis = serde_json::to_value(&app.session.visibility).unwrap_or(json!({}));
+162 -5
View File
@@ -1051,14 +1051,24 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
use crate::browser::{Row, draw_row, is_open, toggle};
let joints: Vec<(u64, String, JointKind, bool)> =
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
app.tree.picked_joints.retain(|p| joints.iter().any(|j| j.0 == *p));
if joints.is_empty() {
return;
}
// Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:<name>`).
// Drawn like every other browser node (its fold, eye and rows publish `fold:Joints`,
// `eye:Joints`, `row:<name>`). The folder's eye hides every joint's glyph, or shows them
// all when none is shown.
let key = "c0/joints";
let open = is_open(app, key, true);
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() });
if r.fold || r.clicked {
let any_visible = joints.iter().any(|j| app.session.joint_visible(j.0));
let r = draw_row(
ui,
ui.id().with(key),
&Row { depth, fold: Some(open), eye: Some(any_visible), icon: "folder", label: "Joints", ..Default::default() },
);
if r.eye {
let _ = app.run("browser.visibility", json!({ "folder": "joints", "visible": !any_visible }));
} else if r.fold || r.clicked {
toggle(app, key, true);
}
if !open {
@@ -1066,16 +1076,29 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
}
let mut dialog: Option<Dialog> = None;
for (id, name, kind, suppressed) in joints {
let r =
draw_row(ui, ui.id().with(("joint", id)), &Row { depth: depth + 1, icon: "joint", label: &name, dim: suppressed, ..Default::default() });
let visible = app.session.joint_visible(id);
let selected = app.tree.picked_joints.contains(&id);
let r = draw_row(
ui,
ui.id().with(("joint", id)),
&Row { depth: depth + 1, eye: Some(visible), icon: "joint", label: &name, selected, dim: suppressed || !visible, ..Default::default() },
);
if let Some(resp) = &r.resp {
resp.clone().on_hover_text(format!("{kind:?} joint"));
}
if r.eye {
let _ = app.run("browser.visibility", json!({ "joints": [id], "visible": !visible }));
} else if r.clicked {
pick_joint(app, id, ui.input(|i| i.modifiers.command || i.modifiers.ctrl));
}
if r.double {
dialog = edit_joint(app, id);
}
// The same menus as the rest of the browser.
if let Some(at) = r.secondary {
if !app.tree.picked_joints.contains(&id) {
app.tree.picked_joints = vec![id];
}
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
}
}
@@ -1085,6 +1108,25 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
}
}
/// A click on a joint's browser row: it becomes the pick (or, with Ctrl, toggles in or out of
/// it). Picked joints draw highlighted in the viewport, even hidden ones.
pub fn pick_joint(app: &mut SolveApp, id: u64, add: bool) {
if !add {
let was = app.tree.picked_joints == [id];
app.tree.picked_joints.clear();
app.tree.picked_canvases.clear();
app.tree.picked_components.clear();
let _ = app.run("select.clear", json!({}));
if !was {
app.tree.picked_joints.push(id);
}
} else if app.tree.picked_joints.contains(&id) {
app.tree.picked_joints.retain(|j| *j != id);
} else {
app.tree.picked_joints.push(id);
}
}
#[cfg(test)]
#[path = "dialogs_assembly_tests.rs"]
mod tests;
@@ -1136,3 +1178,118 @@ pub fn origins(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
painter.circle(c, 4.5, t.panel, Stroke::new(1.5, t.text));
}
}
thread_local! {
/// Joint frames in the world (joint id → A's frame), for the design state they were found in.
static JOINT_FRAMES: std::cell::RefCell<Option<(String, Vec<(u64, solidcraft_engine::doc::Mat)>)>> = const { std::cell::RefCell::new(None) };
}
/// Each joint's frame in the world (where its glyph sits): the frame of its first origin, on
/// its occurrence as placed (including moves not captured yet).
pub fn joint_frames_world(s: &Session) -> Vec<(u64, solidcraft_engine::doc::Mat)> {
if s.doc.assembly.joints.is_empty() {
return Vec::new();
}
let key = format!("{}:{:?}", s.revision, s.pending_moves);
if let Some(v) = JOINT_FRAMES.with(|o| o.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, v)| v.clone())) {
return v;
}
let doc: std::borrow::Cow<solidcraft_engine::doc::Document> = if s.pending_moves.is_empty() {
std::borrow::Cow::Borrowed(&*s.doc)
} else {
let mut d = (*s.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = s.pending_moves.get(&o.id) {
o.transform = *m;
}
}
std::borrow::Cow::Owned(d)
};
let st = s.model.state();
let v: Vec<_> = doc.assembly.joints.iter().filter_map(|j| doc.origin_world(&st, &j.a).ok().map(|m| (j.id, m))).collect();
JOINT_FRAMES.with(|o| *o.borrow_mut() = Some((key, v.clone())));
v
}
/// The joints whose glyph the viewport draws, and whether each is highlighted. A hidden joint
/// isn't drawn (it still constrains and moves the parts), except while it is picked in the
/// browser: then it shows highlighted until the pick goes away.
pub fn drawn_joints(app: &SolveApp) -> Vec<(u64, bool)> {
app.session
.doc
.assembly
.joints
.iter()
.filter_map(|j| {
let picked = app.tree.picked_joints.contains(&j.id);
(picked || app.session.joint_visible(j.id)).then_some((j.id, picked))
})
.collect()
}
/// Joint glyphs in the viewport: a ring where the joint sits, its motion axis, and its limits
/// (ticks on the ring for a turn, along the axis for a slide). Glyphs are only drawn; they are
/// never picked in the viewport.
pub fn joint_glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let drawn = drawn_joints(app);
if drawn.is_empty() {
return;
}
let t = Tokens::get();
let frames = joint_frames_world(&app.session);
let len = app.cam.half_height() * 2.0 * 0.05;
let at = |m: &solidcraft_engine::doc::Mat, x: f64, y: f64, z: f64| solidcraft_engine::doc::apply_point(m, Vec3::new(x, y, z));
for (id, picked) in drawn {
let Some(j) = app.session.doc.assembly.joints.iter().find(|j| j.id == id) else { continue };
let Some((_, m)) = frames.iter().find(|(f, _)| *f == id) else { continue };
let col = if picked {
t.sel_edge
} else if j.suppressed {
t.text_dim
} else {
t.accent
};
let width = if picked { 2.5 } else { 1.5 };
let stroke = Stroke::new(width, col);
let Some(c) = proj.to_screen(at(m, 0.0, 0.0, 0.0)) else { continue };
// The motion axis (z), both ways.
if let (Some(a), Some(b)) = (proj.to_screen(at(m, 0.0, 0.0, -len)), proj.to_screen(at(m, 0.0, 0.0, len))) {
painter.line_segment([a, b], stroke);
}
// A ring about the axis.
let r = len * 0.5;
let ring: Vec<Pos2> = (0..=32)
.filter_map(|k| {
let a = k as f64 / 32.0 * std::f64::consts::TAU;
proj.to_screen(at(m, r * a.cos(), r * a.sin(), 0.0))
})
.collect();
if ring.len() > 1 {
painter.add(egui::Shape::line(ring, stroke));
}
// Limits: ticks across the ring for a turn, across the axis (or slot) for a slide.
for (i, lim) in j.limits.iter().enumerate() {
let Some((lo, hi)) = lim else { continue };
let name = j.kind.dofs().get(i).copied().unwrap_or("");
for v in [*lo, *hi] {
if !v.is_finite() {
continue;
}
let (p, q) = match name {
"angle" => ((v.cos() * r * 0.7, v.sin() * r * 0.7, 0.0), (v.cos() * r * 1.3, v.sin() * r * 1.3, 0.0)),
"distance" if v.abs() < 1e6 => ((-r * 0.3, 0.0, v), (r * 0.3, 0.0, v)),
"slide" if v.abs() < 1e6 => ((v, -r * 0.3, 0.0), (v, r * 0.3, 0.0)),
_ => continue,
};
if let (Some(p), Some(q)) = (proj.to_screen(at(m, p.0, p.1, p.2)), proj.to_screen(at(m, q.0, q.1, q.2))) {
painter.line_segment([p, q], Stroke::new(width, t.warning));
}
}
}
painter.circle(c, if picked { 4.5 } else { 3.5 }, t.panel, stroke);
}
}
#[cfg(test)]
#[path = "joint_browser_tests.rs"]
mod joint_browser_tests;
+112
View File
@@ -0,0 +1,112 @@
//! Joints in the browser (#1160): an eye per joint and on the Joints folder hides and shows
//! joint glyphs; Show/Hide in a joint's menu and the V key do the same. Hiding only hides the
//! drawing: the joint still constrains and drives. A hidden joint picked in the browser draws
//! highlighted until the pick goes away.
use egui::pos2;
use serde_json::json;
use crate::context_menu::{self, Target};
use crate::scenario::Harness;
/// Components A (grounded) and B joined by a revolute "Hinge", and C held by a rigid "Glue".
fn harness() -> Harness {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.show_browser = true;
for (name, x) in [("A", 0), ("B", 30), ("C", 60)] {
h.app.run("component.activate", json!({"component": "root"})).unwrap();
h.app.run("component.create", json!({ "name": name })).unwrap();
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap();
}
h.app.run("component.activate", json!({"component": "root"})).unwrap();
h.app.run("occurrence.ground", json!({"occurrence": "A:1", "grounded": true})).unwrap();
h.app
.run("joint.create", json!({"type": "revolute", "name": "Hinge", "a": {"occurrence": "A:1", "face": [10, 10, 10]}, "b": {"occurrence": "B:1", "face": [40, 10, 0]}}))
.unwrap();
h.app
.run("joint.create", json!({"type": "rigid", "name": "Glue", "a": {"occurrence": "A:1", "face": [20, 10, 5]}, "b": {"occurrence": "C:1", "face": [60, 10, 5]}}))
.unwrap();
h.frames(3);
h
}
fn id(h: &Harness, name: &str) -> u64 {
h.app.session.doc.assembly.joints.iter().find(|j| j.name == name).unwrap().id
}
fn click(h: &mut Harness, handle: &str) {
assert!(crate::scenario::handle_at(handle).is_some(), "{handle} is not drawn: {:?}", crate::scenario::handles(""));
h.step(&json!({"click": {"handle": handle}})).unwrap();
h.frames(3);
}
fn drawn(h: &Harness) -> Vec<(u64, bool)> {
crate::dialogs_assembly::drawn_joints(&h.app)
}
#[test]
fn eyes_hide_and_show_joints_and_the_folder_toggles_all() {
let mut h = harness();
let (hinge, glue) = (id(&h, "Hinge"), id(&h, "Glue"));
assert_eq!(drawn(&h), [(hinge, false), (glue, false)], "every joint draws by default");
click(&mut h, "eye:Hinge");
assert!(!h.app.session.joint_visible(hinge) && h.app.session.joint_visible(glue));
assert_eq!(drawn(&h), [(glue, false)], "a hidden joint isn't drawn");
assert!(crate::scenario::handle_at("row:Hinge").is_some(), "a hidden joint stays listed");
// The folder's eye: some shown → all hidden; none shown → all shown.
click(&mut h, "eye:Joints");
assert!(drawn(&h).is_empty());
click(&mut h, "eye:Joints");
assert_eq!(drawn(&h), [(hinge, false), (glue, false)]);
}
#[test]
fn a_hidden_joint_still_drives_and_shows_while_picked() {
let mut h = harness();
let hinge = id(&h, "Hinge");
click(&mut h, "eye:Hinge");
let b = |h: &Harness| {
let c = h.app.session.doc.find_component("B").unwrap();
h.app.session.doc.occurrence_of(c).unwrap().transform
};
let before = b(&h);
h.app.run("joint.drive", json!({"joint": "Hinge", "value": 90})).unwrap();
h.frames(3);
assert_ne!(before, b(&h), "the hidden hinge still turns B");
assert!(!h.app.session.joint_visible(hinge));
// Picked in the browser, the hidden joint draws highlighted, and only while it is picked.
click(&mut h, "row:Hinge");
assert_eq!(h.app.tree.picked_joints, [hinge]);
assert!(drawn(&h).contains(&(hinge, true)), "{:?}", drawn(&h));
assert!(!h.app.session.joint_visible(hinge), "picking doesn't show it");
crate::viewport::select(&mut h.app, None, false);
assert!(h.app.tree.picked_joints.is_empty());
assert!(!drawn(&h).iter().any(|(j, _)| *j == hinge));
}
#[test]
fn the_menu_and_the_v_key_hide_and_show_picked_joints() {
let mut h = harness();
let (hinge, glue) = (id(&h, "Hinge"), id(&h, "Glue"));
let items = context_menu::items(&h.app, &Target::Joint { id: hinge });
let item = items.iter().find(|i| i.label == "Show/Hide").unwrap_or_else(|| panic!("no Show/Hide in {items:?}")).clone();
assert_eq!(item.shortcut, "V");
context_menu::run_item(&mut h.app, &item, pos2(0.0, 0.0));
assert!(!h.app.session.joint_visible(hinge) && h.app.session.joint_visible(glue));
let items = context_menu::items(&h.app, &Target::Joint { id: hinge });
let item = items.iter().find(|i| i.label == "Show/Hide").unwrap().clone();
context_menu::run_item(&mut h.app, &item, pos2(0.0, 0.0));
assert!(h.app.session.joint_visible(hinge));
// V on the joints picked in the browser.
click(&mut h, "row:Hinge");
h.app.tree.picked_joints.push(glue);
context_menu::toggle_visibility(&mut h.app);
assert!(!h.app.session.joint_visible(hinge) && !h.app.session.joint_visible(glue));
context_menu::toggle_visibility(&mut h.app);
assert!(h.app.session.joint_visible(hinge) && h.app.session.joint_visible(glue));
}
+1
View File
@@ -1190,6 +1190,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
} else if app.dialog.is_some() {
crate::dialogs::cancel(app);
} else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() {
app.tree.picked_joints.clear();
let _ = app.run("select.clear", json!({}));
} else {
// Nothing left to cancel: Esc finishes the sketch.
+5
View File
@@ -2557,6 +2557,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::preview::paint_sketches(app, &painter, &proj);
crate::ref_images::show(app, ui, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
crate::dialogs_assembly::joint_glyphs(app, &painter, &proj);
overlays(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch_text_handles::show(app, ui, &painter, &proj, hover);
@@ -2610,6 +2611,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
/// A click without a command: select the candidate (replacing the selection, or toggling it
/// in with Shift/Ctrl); a click on nothing clears.
pub(crate) fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
if !add {
// A joint picked in the browser (highlighted even when hidden) lets go.
app.tree.picked_joints.clear();
}
match sel {
Some(x) => {
let items = picked_selection(&app.session, &x);