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

+28 -4
View File
@@ -38,8 +38,9 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
"visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
or folder?: bodies|sketches|construction|origin|joints (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
),
];
@@ -170,6 +171,8 @@ enum Shown {
Body(String),
/// Origin items and construction geometry, by name.
Origin(String),
/// An assembly joint's glyph, by joint id.
Joint(u64),
}
fn is_construction(k: &FeatureKind) -> bool {
@@ -188,6 +191,9 @@ fn shown_item(s: &Session, key: &str) -> Option<Shown> {
if s.world_state().bodies.iter().any(|b| b.name == key) {
return Some(Shown::Body(key.to_string()));
}
if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.name == key) {
return Some(Shown::Joint(j.id));
}
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
@@ -255,12 +261,24 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
}
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
_ if p.get("joints").is_some() => {
if p.get("items").is_some() || p.get("folder").is_some() {
return Err(bad(cmd, "give `joints`, `items` or `folder`, not several"));
}
let list = p
.get("joints")
.and_then(Value::as_array)
.filter(|a| !a.is_empty() && a.len() <= 10_000)
.ok_or_else(|| bad(cmd, "`joints` must list 1..10000 joint ids or names"))?;
list.iter().map(|j| super::joints::joint_id(s, Some(j), cmd).map(Shown::Joint)).collect::<Result<_>>()?
}
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
(Some("joints"), None) => s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)).collect(),
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin, joints)"))),
(None, Some(_)) => {
let keys = keys(p, "items", cmd)?;
if keys.is_empty() {
@@ -268,7 +286,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
}
// Every item is found before anything changes.
keys.iter()
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body, construction item or joint `{k}`"))))
.collect::<Result<_>>()?
}
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
@@ -296,6 +314,12 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
v.hidden_origin.push(n.clone());
}
}
Shown::Joint(id) => {
v.hidden_joints.retain(|x| x != id);
if !visible {
v.hidden_joints.push(*id);
}
}
}
}
s.revision += 1;
+3 -1
View File
@@ -234,6 +234,8 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
}
let id = j.id;
let name = j.name.clone();
// A new joint can take a deleted one's id; it starts shown.
s.visibility.hidden_joints.retain(|h| *h != id);
s.doc_mut().assembly.joints.push(j);
let sol = resolve(s);
let mut out = sol_json(&sol);
@@ -519,7 +521,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
.collect();
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "frame_a": world(&j.a), "frame_b": world(&j.b)})
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)})
})
.collect();
let links: Vec<Value> =
+122
View File
@@ -720,3 +720,125 @@ fn a_clamped_link_is_followed_step_by_step() {
assert!(r["stopped_by_loop"].is_object(), "{r}");
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-joint-visibility-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("design.{ext}")).to_string_lossy().to_string();
(dir, path)
}
/// Two hinge leaves joined by a revolute "Hinge" and a rigid "Glue" to a third part.
fn hinged() -> Session {
let mut s = Session::default();
leaf(&mut s, "LeafA");
leaf(&mut s, "LeafB");
part(&mut s, "Tag", [60.0, 0.0, 0.0]);
let o = occ(&s, "LeafA");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
run(
&mut s,
"joint.create",
json!({"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
);
pin(&mut s, "rigid", "Glue", "LeafA", "Tag", Vec3::new(61.0, 1.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
s
}
fn joint(s: &Session, name: &str) -> u64 {
s.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap()
}
/// #1160: hiding a joint hides only its glyph. It is saved with the design (not an undo step,
/// like other visibility), the hinge still drives its leaf while hidden, and the folder hides
/// and shows every joint at once.
#[test]
fn hidden_joints_still_drive_and_are_saved_with_the_design() {
let (dir, path) = temp_design("saved", "json");
let mut s = hinged();
let (hinge, glue) = (joint(&s, "Hinge"), joint(&s, "Glue"));
run(&mut s, "file.save", json!({"path": path}));
let undo = s.undo.len();
let r = run(&mut s, "browser.visibility", json!({"joints": ["Hinge"], "visible": false}));
assert_eq!(r["changed"], 1, "{r}");
assert!(!s.joint_visible(hinge) && s.joint_visible(glue));
assert_eq!(s.undo.len(), undo, "hiding is not an undo step");
assert!(s.is_dirty(), "hiding leaves unsaved changes");
let l = run(&mut s, "joint.list", json!({}));
assert_eq!(l["joints"].as_array().unwrap().iter().map(|j| j["visible"].clone()).collect::<Vec<_>>(), [json!(false), json!(true)]);
// Hidden, the hinge still constrains and moves its leaf; undoing the drive keeps it hidden.
let p = Vec3::new(20.0, 0.0, 0.0);
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)), "{:?}", place(&s, "LeafB", p));
run(&mut s, "edit.undo", json!({}));
assert!(near(place(&s, "LeafB", p), p));
assert!(!s.joint_visible(hinge), "undo keeps the hiding");
run(&mut s, "edit.redo", json!({}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
// Saved and opened again, the hinge stays hidden.
run(&mut s, "file.save", json!({}));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
let (hinge, glue) = (joint(&s, "Hinge"), joint(&s, "Glue"));
assert!(!s.joint_visible(hinge) && s.joint_visible(glue), "{:?}", s.visibility);
assert!(!s.is_dirty());
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["visibility"]["hidden_joints"], json!([hinge]));
// The folder: all hidden, then all shown (and saved without any hidden joints).
run(&mut s, "browser.visibility", json!({"folder": "joints", "visible": false}));
assert!(!s.joint_visible(hinge) && !s.joint_visible(glue));
run(&mut s, "browser.visibility", json!({"folder": "joints", "visible": true}));
assert!(s.joint_visible(hinge) && s.joint_visible(glue));
// By name among other items, too.
run(&mut s, "browser.visibility", json!({"items": ["Glue"], "visible": false}));
assert!(!s.joint_visible(glue));
run(&mut s, "browser.visibility", json!({"items": ["Glue"], "visible": true}));
run(&mut s, "file.save", json!({}));
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(saved.get("visibility").is_none_or(|v| v.get("hidden_joints").is_none()), "{saved}");
// Bad requests change nothing.
let before = s.visibility.clone();
for p in [
json!({"joints": ["Nope"], "visible": false}),
json!({"joints": [], "visible": false}),
json!({"joints": "Hinge", "visible": false}),
json!({"joints": ["Hinge"], "folder": "joints", "visible": false}),
json!({"joints": ["Hinge"], "items": ["Glue"], "visible": false}),
] {
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
}
assert_eq!(s.visibility, before);
let _ = std::fs::remove_dir_all(&dir);
}
/// #1160: a design saved before joints could be hidden (format 19, no `hidden_joints`) opens
/// with every joint shown; and a new joint that takes a deleted hidden joint's id starts shown.
#[test]
fn older_designs_show_every_joint_and_new_joints_start_shown() {
let (dir, path) = temp_design("legacy", "json");
let mut s = hinged();
let mut legacy: Value = serde_json::from_slice(&solidcraft_io::write_design(&s.doc)).unwrap();
legacy["format"] = json!("solidcraft/19");
legacy["visibility"] =
json!({"hidden_bodies": [], "hidden_occurrences": [], "hidden_origin": ["XY"], "hidden_sketches": [], "shown_sketches": []});
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert_eq!(s.doc.assembly.joints.len(), 2);
assert!(s.doc.assembly.joints.iter().all(|j| s.joint_visible(j.id)));
assert_eq!(s.visibility.hidden_origin, ["XY"]);
let glue = joint(&s, "Glue");
run(&mut s, "browser.visibility", json!({"joints": [glue], "visible": false}));
run(&mut s, "joint.delete", json!({"joint": glue}));
pin(&mut s, "rigid", "Glue2", "LeafA", "Tag", Vec3::new(61.0, 1.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
let again = joint(&s, "Glue2");
assert!(s.joint_visible(again), "id {again} reused from {glue}");
let _ = std::fs::remove_dir_all(&dir);
}