From 229de1484ce7c4df3c28df5e5c111078ca9e53a3 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 15:04:57 -0400 Subject: [PATCH] Hide and show joints in the browser, like other items (#1168) Fixes #1160. --- crates/doc/src/document.rs | 4 + crates/doc/src/format.rs | 3 + crates/engine/src/cmd/browser.rs | 32 ++++- crates/engine/src/cmd/joints.rs | 4 +- crates/engine/src/cmd/joints_tests.rs | 122 ++++++++++++++++ crates/engine/src/occurrences.rs | 6 + crates/ui-egui/src/browser.rs | 4 + crates/ui-egui/src/context_menu.rs | 16 ++- crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs_assembly.rs | 167 +++++++++++++++++++++- crates/ui-egui/src/joint_browser_tests.rs | 112 +++++++++++++++ crates/ui-egui/src/toolbar.rs | 1 + crates/ui-egui/src/viewport.rs | 5 + docs/control-protocol.md | 4 +- docs/mcp.md | 2 +- 15 files changed, 470 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/joint_browser_tests.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0b805d75..10ceb954 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1709,6 +1709,9 @@ pub struct Visibility { pub hidden_sketches: Vec, /// Finished sketches shown although a feature uses them. pub shown_sketches: Vec, + /// Assembly joints by id: their glyphs are not drawn (they still constrain and move). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub hidden_joints: Vec, } impl Visibility { @@ -1719,6 +1722,7 @@ impl Visibility { && self.hidden_origin.is_empty() && self.hidden_sketches.is_empty() && self.shown_sketches.is_empty() + && self.hidden_joints.is_empty() } } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 8fd7f423..3ef957af 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -150,6 +150,9 @@ //! Associative, editable sketch patterns (`patterns` in a sketch) and their point/radius //! relations are also first saved in 20. Old sketch copies stay independent: migration cannot //! infer which curves formed a pattern. +//! The saved visibility can also list hidden assembly joints (`visibility.hidden_joints`, +//! joint ids): their glyphs aren't drawn, but they still constrain and drive the parts. A +//! design without it shows every joint, as it always did; 0.10.0 would show them all. use serde_json::Value; diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 35876873..e5863195 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -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 { 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 { return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility})); } let items: Vec = 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::>()? + } (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 { } // 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::>()? } (None, None) => return Err(bad(cmd, "`items` or `folder` is required")), @@ -296,6 +314,12 @@ fn visibility(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index c2ce3059..a638e5b0 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -234,6 +234,8 @@ fn add_joint(s: &mut Session, j: Joint) -> Result { } 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 { .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 = diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 932183a2..fd8ad9ee 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -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::>(), [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); +} diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs index 8ed49188..7eeea30d 100644 --- a/crates/engine/src/occurrences.rs +++ b/crates/engine/src/occurrences.rs @@ -176,6 +176,12 @@ impl Session { Ok(count) } + /// Whether a joint's glyph is drawn (`browser.visibility`). Hiding a joint only hides its + /// drawing: it still constrains and drives the parts. + pub fn joint_visible(&self, id: u64) -> bool { + !self.visibility.hidden_joints.contains(&id) + } + pub fn body_visible(&self, name: &str) -> bool { if self.visibility.hidden_bodies.iter().any(|n| n == name) { return false; diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 37e928cc..1d3a5e0e 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -57,6 +57,8 @@ pub struct TreeState { pub picked_components: Vec, /// Canvases picked in the tree. pub picked_canvases: Vec, + /// Joints picked in the tree (a hidden one shows highlighted while it is picked). + pub picked_joints: Vec, /// Folders picked in the feature history (features use the engine selection). pub picked_timeline_groups: Vec, /// Last feature-history item clicked, for Shift ranges (`f:` or `g:`). @@ -429,6 +431,7 @@ fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option } } 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 { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 116e84e5..82586ee9 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -550,6 +550,12 @@ fn plane_items(app: &SolveApp, id: u64) -> Vec { fn joint_items(app: &SolveApp, id: u64) -> Vec { 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::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> = @@ -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 })); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cc851035..7f5f5f6d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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!({})); diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 1db5b1e8..3be668a5 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -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:`). + // Drawn like every other browser node (its fold, eye and rows publish `fold:Joints`, + // `eye:Joints`, `row:`). 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 = 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)>> = 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 = 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 = (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; diff --git a/crates/ui-egui/src/joint_browser_tests.rs b/crates/ui-egui/src/joint_browser_tests.rs new file mode 100644 index 00000000..b4ca0a63 --- /dev/null +++ b/crates/ui-egui/src/joint_browser_tests.rs @@ -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)); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5a65900b..903b222b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 88760e99..77057d62 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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, 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); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a6ef1dae..a3f955f0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -97,8 +97,8 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | UI state, what is hidden (`visibility`: bodies, origin and construction items, sketches; set it with the `browser.visibility` command), viewport rect, camera, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | -| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | +| `ui.inspect` | — | UI state, what is hidden (`visibility`: bodies, origin and construction items, sketches, joints (`hidden_joints`, joint ids); set it with the `browser.visibility` command), viewport rect, camera, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | +| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | diff --git a/docs/mcp.md b/docs/mcp.md index 69c272a8..c648833c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -124,7 +124,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t | `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. | | `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. | | `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. | -| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`). | +| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). | | `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. | | `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |