diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index a7e3beb2..5bb71272 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use solvecraft_doc::FeatureKind; use super::CommandSpec; -use crate::params::{bad, str_}; +use crate::params::{bad, bool_, str_}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ @@ -34,6 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"), CommandSpec::new("view.list", "List Named Views", view_list).noundo(), 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). View state: not saved, not an undo step", + ), ]; const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"]; @@ -155,6 +159,83 @@ fn order(s: &mut Session, p: &Value) -> Result { Ok(json!({})) } +const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"]; + +/// A browser item whose visibility can change. +enum Shown { + Sketch(u64), + Body(String), + /// Origin items and construction geometry, by name. + Origin(String), +} + +fn is_construction(k: &FeatureKind) -> bool { + matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. }) +} + +fn shown_item(s: &Session, key: &str) -> Option { + if let Some(f) = s.doc.find_feature(key) { + if matches!(f.kind, FeatureKind::Sketch { .. }) { + return Some(Shown::Sketch(f.id)); + } + if is_construction(&f.kind) { + return Some(Shown::Origin(f.name.clone())); + } + } + if s.world_state().bodies.iter().any(|b| b.name == key) { + return Some(Shown::Body(key.to_string())); + } + ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string())) +} + +fn visibility(s: &mut Session, p: &Value) -> Result { + let cmd = "browser.visibility"; + let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?; + let items: Vec = match (str_(p, "folder"), p.get("items")) { + (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)"))), + (None, Some(_)) => { + let keys = keys(p, "items", cmd)?; + if keys.is_empty() { + return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry")); + } + // 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}`")))) + .collect::>()? + } + (None, None) => return Err(bad(cmd, "`items` or `folder` is required")), + }; + let v = &mut s.visibility; + for it in &items { + match it { + Shown::Sketch(id) => { + v.hidden_sketches.retain(|x| x != id); + v.shown_sketches.retain(|x| x != id); + if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) } + } + Shown::Body(n) => { + v.hidden_bodies.retain(|x| x != n); + if !visible { + v.hidden_bodies.push(n.clone()); + } + } + Shown::Origin(n) => { + v.hidden_origin.retain(|x| x != n); + if !visible { + v.hidden_origin.push(n.clone()); + } + } + } + } + s.revision += 1; + Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility })) +} + fn document_units(s: &mut Session, p: &Value) -> Result { let cmd = "document.units"; let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?; @@ -339,6 +420,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { d.body_components.insert(name.to_string(), c); } rename_key(d, "bodies", &old, name); + for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) { + *b = name.to_string(); + } Ok(json!({"body": old, "name": name})) } @@ -478,6 +562,60 @@ mod tests { assert_eq!(s.doc.named_views.len(), 2); } + #[test] + fn helper_sketches_hide_and_show() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true})); + run(&mut s, "sketch.finish", json!({})); + let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.point", json!({"point": [25, 5]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"})); + let body = s.model.state().bodies[0].name.clone(); + let drain_name = s.doc.feature(drain).unwrap().name.clone(); + let cam = solvecraft_render::Camera::default(); + let lines = |s: &Session| crate::view::scene(s, &cam).lines.len(); + let shown = |s: &mut Session| { + let d = run(s, "document.inspect", json!({})); + d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count() + }; + let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone()); + assert_eq!(shown(&mut s), 2); + // By id and by name; the view loses their lines, the design and its history don't change. + let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false})); + assert_eq!(r["changed"], 2); + assert_eq!(shown(&mut s), 0); + assert!(lines(&s) < n0); + assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change"); + run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true})); + assert_eq!(lines(&s), n0); + // Bodies (following a rename) and construction planes. + run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false})); + assert!(crate::view::scene(&s, &cam).meshes.is_empty()); + assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]); + run(&mut s, "body.rename", json!({"body": body, "name": "Tray"})); + assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]); + let d = run(&mut s, "document.inspect", json!({})); + assert_eq!(d["bodies"][0]["visible"], false); + // Hostile input is refused and changes nothing. + let before = s.visibility.clone(); + for p in [ + json!({"items": [floor]}), + json!({"items": [floor], "visible": "no"}), + json!({"items": [floor, "Nope"], "visible": false}), + json!({"items": [], "visible": false}), + json!({"items": "Floor", "visible": false}), + json!({"folder": "sketches", "items": [floor], "visible": false}), + json!({"folder": "nope", "visible": false}), + json!({"visible": false}), + ] { + assert!(s.execute("browser.visibility", &p).is_err(), "{p}"); + } + assert_eq!(s.visibility, before); + } + #[test] fn groups_hold_items_and_follow_renames() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 7a9d558b..342a29e7 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -170,6 +170,7 @@ fn sketch_json(s: &Session, id: u64) -> Option { "profiles": profiles, "links": sk.links, "wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::>(), + "visible": crate::view::sketch_shown(s, id), })) } @@ -207,18 +208,27 @@ fn inspect(s: &mut Session, p: &Value) -> Result { }) .collect(); let with_measure = bool_(p, "measure").unwrap_or(false); - let bodies: Vec = - st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect(); + let bodies: Vec = st + .bodies + .iter() + .map(|b| { + let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }; + if let Some(o) = v.as_object_mut() { + o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name))); + } + v + }) + .collect(); let sketches: Vec = st .sketches .iter() - .map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()})) + .map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)})) .collect(); Ok(json!({ "name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision, "active_sketch": s.active_sketch, "marker": marker, "params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches, - "selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), + "selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin, })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5c43f68f..2ce89495 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -168,6 +168,22 @@ pub struct Session { pub section: Option<(Vec3, Vec3)>, /// Surface analysis shading the model (zebra, draft, curvature map). pub analysis: Option, + /// What is hidden or shown in the view (`browser.visibility`). + pub visibility: Visibility, +} + +/// Items hidden or shown one by one (view state, not part of the design and not an undo step). +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct Visibility { + /// Bodies by name. + pub hidden_bodies: Vec, + /// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name. + pub hidden_origin: Vec, + /// Sketches by feature id. + pub hidden_sketches: Vec, + /// Finished sketches shown although a feature uses them. + pub shown_sketches: Vec, } /// A way of shading the model's faces to judge their shape (view state, not part of the design). @@ -219,6 +235,7 @@ impl Session { clipboard: Vec::new(), section: None, analysis: None, + visibility: Visibility::default(), } } @@ -360,6 +377,7 @@ impl Session { clipboard: self.clipboard.clone(), section: None, analysis: None, + visibility: Visibility::default(), } } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 512d86cc..52b3a675 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -41,10 +41,11 @@ pub mod colors { pub fn bounds(s: &Session) -> Aabb3 { let st = s.world_state(); let mut b = Aabb3::EMPTY; - for body in &st.bodies { + let v = &s.visibility; + for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) { b = b.union(&body.mesh().bounds()); } - for ss in &st.sketches { + for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) { if let Some((lo, hi)) = ss.sketch.bounds() { for p in [lo, hi] { b.add(ss.plane.to_world(p)); @@ -151,7 +152,7 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false }); } - for body in &st.bodies { + for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) { let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); let mesh = body.mesh(); // The body's appearance (or imported colour) and faces with looks of their own, as in @@ -174,8 +175,7 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { } for ss in &st.sketches { let active = s.active_sketch == Some(ss.feature); - // Finished sketches are hidden once a feature uses them, like Fusion's default. - if !active && sketch_consumed(s, ss.feature) { + if !sketch_shown(s, ss.feature) { continue; } for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { @@ -185,6 +185,13 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene { sc } +/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden +/// once a feature uses them, like Fusion's default, unless shown one by one. +pub fn sketch_shown(s: &Session, id: u64) -> bool { + let v = &s.visibility; + s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id))) +} + /// Is a sketch used by a later feature? pub fn sketch_consumed(s: &Session, id: u64) -> bool { use solvecraft_doc::FeatureKind; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 2c102353..f050cd49 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -213,6 +213,23 @@ fn batch_stops_at_first_error_and_rolls_back() { assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2); } +#[test] +fn helper_sketches_can_be_hidden() { + let mut s = server(); + tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 40, "height": 2}})); + let sk = tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}))["sketch"].clone(); + tool(&mut s, "execute", json!({"command": "sketch.line", "params": {"points": [[0, 0], [40, 0]], "construction": true}})); + tool(&mut s, "execute", json!({"command": "sketch.finish"})); + let visible = |s: &mut Server| tool(s, "inspect_design", json!({"measure": false}))["sketches"][0]["visible"].clone(); + assert_eq!(visible(&mut s), true); + tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": [sk], "visible": false}})); + assert_eq!(visible(&mut s), false); + let r = raw_tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Nope"], "visible": false}})); + assert_eq!(r["result"]["isError"], true); + tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "sketches", "visible": true}})); + assert_eq!(visible(&mut s), true); +} + #[test] fn unknown_tools_and_bad_arguments_are_clean_errors() { let mut s = server(); diff --git a/crates/ui-egui/src/access.rs b/crates/ui-egui/src/access.rs index 5fe61cad..7fc001e4 100644 --- a/crates/ui-egui/src/access.rs +++ b/crates/ui-egui/src/access.rs @@ -27,7 +27,7 @@ pub fn map(app: &SolveApp) -> Option { return Some(m); } let st = app.session.world_state(); - let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect(); + let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect(); // Every new map gets a new version (the GPU uploads it once). let version = VERSION.with(|v| { v.set(v.get() + 1); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 9fc145cb..f073b10a 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -80,19 +80,19 @@ impl OccMove { /// Is a sketch drawn in the viewport? pub fn sketch_visible(app: &SolveApp, id: u64) -> bool { - if app.ui.hidden_sketches.contains(&id) { + if app.session.visibility.hidden_sketches.contains(&id) { return false; } - app.ui.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id)) + app.session.visibility.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id)) } pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) { - app.ui.hidden_sketches.retain(|x| *x != id); - app.ui.shown_sketches.retain(|x| *x != id); + app.session.visibility.hidden_sketches.retain(|x| *x != id); + app.session.visibility.shown_sketches.retain(|x| *x != id); if on { - app.ui.shown_sketches.push(id); + app.session.visibility.shown_sketches.push(id); } else { - app.ui.hidden_sketches.push(id); + app.session.visibility.hidden_sketches.push(id); } } @@ -126,7 +126,7 @@ pub fn group_items(app: &SolveApp, id: u64) -> Vec { ]; if g.folder == "bodies" { let bodies = group_bodies(app, id); - let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b)); + let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b)); v.push(Item::sep()); v.push(Item::action(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": bodies }))); v.push(Item::action("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies }))); @@ -158,8 +158,8 @@ pub fn folder_items(app: &SolveApp, component: u64, folder: &str) -> Vec { /// The menu of the Origin folder. pub fn origin_items(app: &SolveApp) -> Vec { - let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p)); - let axes = ["X", "Y", "Z"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p)); + let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p)); + let axes = ["X", "Y", "Z"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p)); vec![ Item::action("ui.origin", "Show/Hide", "eye").key("V"), Item::action("ui.originAll", "Show All", "eye"), @@ -520,7 +520,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, let open = is_open(app, &key, true); let active = app.session.active_component == id; let bodies = crate::context_menu::component_bodies(app, id); - let visible = bodies.is_empty() || bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b)); + let visible = bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b)); let grounded = app.session.doc.occurrence_of(id).is_some_and(|o| o.grounded); let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active; let name = component_name(app, id); @@ -546,9 +546,12 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, } if r.eye { if visible { - app.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::>()); + app.session + .visibility + .hidden_bodies + .extend(bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(b)).cloned().collect::>()); } else { - app.ui.hidden_bodies.retain(|b| !bodies.contains(b)); + app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)); } } if r.clicked { @@ -661,7 +664,7 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { ("YZ", "plane", "YZ"), ]; for (k, icon, label) in items { - let visible = !app.ui.hidden_origin.iter().any(|h| h == k); + let visible = !app.session.visibility.hidden_origin.iter().any(|h| h == k); let sel = match icon { "plane" => Some(Sel::Plane { name: k.into() }), "axis" => Some(Sel::Axis { name: k.into() }), @@ -675,9 +678,9 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { ); if r.eye { if visible { - app.ui.hidden_origin.push(k.into()); + app.session.visibility.hidden_origin.push(k.into()); } else { - app.ui.hidden_origin.retain(|h| h != k); + app.session.visibility.hidden_origin.retain(|h| h != k); } } if r.clicked @@ -1001,7 +1004,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { row_key: format!("b:{}", b.name), label: b.name.clone(), icon: "body", - visible: !app.ui.hidden_bodies.contains(&b.name), + visible: !app.session.visibility.hidden_bodies.contains(&b.name), selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)), color: None, locked: app.ui.locked_bodies.contains(&b.name), @@ -1040,7 +1043,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { row_key: format!("p:{}", f.id), label: f.name.clone(), icon: "plane", - visible: !app.ui.hidden_origin.contains(&f.name), + visible: !app.session.visibility.hidden_origin.contains(&f.name), selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)), color: None, locked: false, @@ -1079,9 +1082,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) { for e in items { match folder { "bodies" => { - app.ui.hidden_bodies.retain(|b| *b != e.key); + app.session.visibility.hidden_bodies.retain(|b| *b != e.key); if !on { - app.ui.hidden_bodies.push(e.key.clone()); + app.session.visibility.hidden_bodies.push(e.key.clone()); } } "sketches" => { @@ -1090,9 +1093,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) { } } _ => { - app.ui.hidden_origin.retain(|p| *p != e.label); + app.session.visibility.hidden_origin.retain(|p| *p != e.label); if !on { - app.ui.hidden_origin.push(e.label.clone()); + app.session.visibility.hidden_origin.push(e.label.clone()); } } } diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index fc563cb0..6381df63 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -358,7 +358,7 @@ fn viewport_items(app: &SolveApp) -> Vec { if has(|s| matches!(s, Sel::Face { .. })) { let face = sel.iter().find_map(|s| if let Sel::Face { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default(); let (feature, sketch) = body_feature_and_sketch(app, &face); - let hidden = app.ui.hidden_bodies.contains(&face); + let hidden = app.session.visibility.hidden_bodies.contains(&face); v.push(cmd(app, "sketch.create", "Create Sketch")); v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "construct.plane.offset", "Offset Plane")); @@ -379,7 +379,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(cmd(app, "inspect.measure", "Measure")); } else if has(|s| matches!(s, Sel::Edge { .. })) { let body = sel.iter().find_map(|s| if let Sel::Edge { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default(); - let hidden = app.ui.hidden_bodies.contains(&body); + let hidden = app.session.visibility.hidden_bodies.contains(&body); v.push(cmd(app, "solid.fillet", "Fillet")); v.push(cmd(app, "solid.chamfer", "Chamfer")); v.push(Item::sep()); @@ -403,9 +403,9 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(act("ui.fit", "Fit", "fit")); v.push(act("ui.home", "Home View", "home")); v.push(Item::sep()); - let anything_hidden = !app.ui.hidden_bodies.is_empty() || !app.ui.hidden_sketches.is_empty(); + let anything_hidden = !app.session.visibility.hidden_bodies.is_empty() || !app.session.visibility.hidden_sketches.is_empty(); v.push(act("ui.showAll", "Show All", "eye").on(anything_hidden)); - v.push(act("ui.showAll", "Unisolate", "").on(!app.ui.hidden_bodies.is_empty())); + v.push(act("ui.showAll", "Unisolate", "").on(!app.session.visibility.hidden_bodies.is_empty())); v.push(Item::sep()); v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "solid.fillet", "Fillet")); @@ -428,7 +428,7 @@ fn viewport_items(app: &SolveApp) -> Vec { fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { let names = json!(bodies); - let all_hidden = bodies.iter().all(|b| app.ui.hidden_bodies.contains(b)); + let all_hidden = bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b)); let one = bodies.len() == 1; let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b)); let items = json!(bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::>()); @@ -506,7 +506,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { let occ = doc.occurrence_of(id); let active = app.session.active_component == id; let bodies = component_bodies(app, id); - let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b)); + let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b)); let names = json!(bodies); let mut v = Vec::new(); if id != 0 { @@ -666,18 +666,19 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { "ui.finishSketch" => app.finish_sketch(), "ui.hide" => { for b in bodies { - if !app.ui.hidden_bodies.contains(&b) { - app.ui.hidden_bodies.push(b); + if !app.session.visibility.hidden_bodies.contains(&b) { + app.session.visibility.hidden_bodies.push(b); } } } - "ui.show" => app.ui.hidden_bodies.retain(|b| !bodies.contains(b)), + "ui.show" => app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)), "ui.showAll" => { - app.ui.hidden_bodies.clear(); - app.ui.hidden_sketches.clear(); + app.session.visibility.hidden_bodies.clear(); + app.session.visibility.hidden_sketches.clear(); } "ui.isolate" => { - app.ui.hidden_bodies = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect(); + app.session.visibility.hidden_bodies = + app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect(); } "ui.lock" => { let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b)); @@ -822,14 +823,14 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } "ui.originAll" => { app.ui.show_origin = true; - app.ui.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str())); + app.session.visibility.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str())); } "ui.originPart" => { let show = p.get("show").and_then(Value::as_bool).unwrap_or(true); for k in strs(&p, "keys") { - app.ui.hidden_origin.retain(|h| *h != k); + app.session.visibility.hidden_origin.retain(|h| *h != k); if !show { - app.ui.hidden_origin.push(k); + app.session.visibility.hidden_origin.push(k); } } } @@ -925,16 +926,16 @@ pub fn toggle_visibility(app: &mut SolveApp) { }) .collect(); let canvases = app.tree.picked_canvases.clone(); - let any_visible = bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b)) + let any_visible = bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b)) || sketches.iter().any(|s| crate::browser::sketch_visible(app, *s)) - || origin.iter().any(|o| !app.ui.hidden_origin.contains(o)) + || origin.iter().any(|o| !app.session.visibility.hidden_origin.contains(o)) || app.session.doc.canvases.iter().any(|c| canvases.contains(&c.id) && c.visible); let hide = any_visible; - app.ui.hidden_bodies.retain(|b| !bodies.contains(b)); - app.ui.hidden_origin.retain(|o| !origin.contains(o)); + app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)); + app.session.visibility.hidden_origin.retain(|o| !origin.contains(o)); if hide { - app.ui.hidden_bodies.extend(bodies); - app.ui.hidden_origin.extend(origin); + app.session.visibility.hidden_bodies.extend(bodies); + app.session.visibility.hidden_origin.extend(origin); } for s in sketches { crate::browser::set_sketch_visible(app, s, !hide); @@ -1007,7 +1008,7 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) { match &r.what { RenameWhat::Body(old) => { if app.run("body.rename", json!({ "body": old, "name": name })).is_ok() { - for list in [&mut app.ui.hidden_bodies, &mut app.ui.locked_bodies] { + for list in [&mut app.session.visibility.hidden_bodies, &mut app.ui.locked_bodies] { for b in list.iter_mut().filter(|b| *b == old) { *b = name.clone(); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2080aedd..f5316b4f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -119,6 +119,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())), "ui.inspect" => ok(json!({ "ui": serde_json::to_value(&app.ui).unwrap_or_default(), + "visibility": app.session.visibility, "viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])), "camera": app.cam, "tool": app.tool.as_ref().map(|t| t.cmd), @@ -149,18 +150,30 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> ok(json!({"open": app.home.open, "recent": app.home.recent})) } "ui.set" => { + // What is hidden lives in the session (`browser.visibility`); `ui.set` still sets it. + const VIS: [(&str, &str); 4] = [ + ("hiddenBodies", "hidden_bodies"), + ("hiddenOrigin", "hidden_origin"), + ("hiddenSketches", "hidden_sketches"), + ("shownSketches", "shown_sketches"), + ]; let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({})); - if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) { + let mut vis = serde_json::to_value(&app.session.visibility).unwrap_or(json!({})); + if let (Some(o), Some(vo), Some(src)) = (cur.as_object_mut(), vis.as_object_mut(), p.as_object()) { for (k, v) in src { - o.insert(k.clone(), v.clone()); + match VIS.iter().find(|(camel, _)| camel == k) { + Some((_, snake)) => vo.insert(snake.to_string(), v.clone()), + None => o.insert(k.clone(), v.clone()), + }; } } - match serde_json::from_value::(cur) { - Ok(u) => { + match (serde_json::from_value::(cur), serde_json::from_value::(vis)) { + (Ok(u), Ok(v)) => { app.ui = u; + app.session.visibility = v; ok(serde_json::to_value(&app.ui).unwrap_or_default()) } - Err(e) => err(e), + (Err(e), _) | (_, Err(e)) => err(e), } } "ui.view" => { diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index c749c7c6..dfe441a3 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -27,10 +27,6 @@ pub struct DocSlot { /// The per-design part of the UI state (what is hidden, shown or locked). #[derive(Clone, Debug, Default)] struct DocView { - hidden_bodies: Vec, - hidden_origin: Vec, - hidden_sketches: Vec, - shown_sketches: Vec, hidden_profiles: Vec, shown_dims: Vec, locked_bodies: Vec, @@ -63,10 +59,6 @@ impl Documents { fn take_view(app: &mut SolveApp) -> DocView { let u = &mut app.ui; DocView { - hidden_bodies: std::mem::take(&mut u.hidden_bodies), - hidden_origin: std::mem::take(&mut u.hidden_origin), - hidden_sketches: std::mem::take(&mut u.hidden_sketches), - shown_sketches: std::mem::take(&mut u.shown_sketches), hidden_profiles: std::mem::take(&mut u.hidden_profiles), shown_dims: std::mem::take(&mut u.shown_dims), locked_bodies: std::mem::take(&mut u.locked_bodies), @@ -75,10 +67,6 @@ fn take_view(app: &mut SolveApp) -> DocView { fn put_view(app: &mut SolveApp, v: DocView) { let u = &mut app.ui; - u.hidden_bodies = v.hidden_bodies; - u.hidden_origin = v.hidden_origin; - u.hidden_sketches = v.hidden_sketches; - u.shown_sketches = v.shown_sketches; u.hidden_profiles = v.hidden_profiles; u.shown_dims = v.shown_dims; u.locked_bodies = v.locked_bodies; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8b422eb0..d4093555 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,9 +101,6 @@ pub struct UiState { pub show_grid: bool, pub show_origin: bool, pub perspective: bool, - pub hidden_bodies: Vec, - /// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction planes hidden one by one. - pub hidden_origin: Vec, pub show_sketches: bool, pub palette_open: bool, /// Dark theme (the default); a preference kept between runs. @@ -118,9 +115,6 @@ pub struct UiState { pub ground_shadow: bool, /// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it. pub up_axis: solvecraft_engine::render::UpAxis, - /// Sketches hidden one by one, and finished sketches shown although a feature uses them. - pub hidden_sketches: Vec, - pub shown_sketches: Vec, /// Sketches whose closed profiles are not shaded. pub hidden_profiles: Vec, /// Finished sketches whose dimensions are shown. @@ -140,8 +134,6 @@ impl Default for UiState { show_grid: true, show_origin: true, perspective: false, - hidden_bodies: Vec::new(), - hidden_origin: Vec::new(), show_sketches: true, palette_open: false, dark: true, @@ -150,8 +142,6 @@ impl Default for UiState { visual_style: 0, ground_shadow: true, up_axis: Default::default(), - hidden_sketches: Vec::new(), - shown_sketches: Vec::new(), hidden_profiles: Vec::new(), shown_dims: Vec::new(), locked_bodies: Vec::new(), @@ -594,7 +584,7 @@ impl SolveApp { pub fn origin_visible(&self) -> bool { self.ui.show_origin || (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch)) - && !self.session.world_state().bodies.iter().any(|b| !self.ui.hidden_bodies.contains(&b.name))) + && !self.session.world_state().bodies.iter().any(|b| !self.session.visibility.hidden_bodies.contains(&b.name))) } pub fn set_status(&mut self, s: impl Into, error: bool) { diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 1fb5800e..b8c2b7e0 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -58,7 +58,7 @@ fn face_menu_hides_and_finds_the_body() { context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0)); assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }])); context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0)); - assert_eq!(app.ui.hidden_bodies, vec![b]); + assert_eq!(app.session.visibility.hidden_bodies, vec![b]); } #[test] @@ -69,8 +69,8 @@ fn body_menu_isolates_renames_locks_and_deletes() { let t = Target::Body { name: b.clone() }; let items = context_menu::items(&app, &t); context_menu::run_item(&mut app, &find(&items, "Isolate"), pos2(0.0, 0.0)); - assert_eq!(app.ui.hidden_bodies.len(), 1, "the other body is hidden"); - assert!(!app.ui.hidden_bodies.contains(&b)); + assert_eq!(app.session.visibility.hidden_bodies.len(), 1, "the other body is hidden"); + assert!(!app.session.visibility.hidden_bodies.contains(&b)); // Rename through the rename box. context_menu::run_item(&mut app, &find(&items, "Rename"), pos2(10.0, 10.0)); assert!(context_menu::finish_rename(&mut app, Some("Base"), true)); @@ -184,7 +184,7 @@ fn folder_and_group_menus_make_rename_and_drop_groups() { assert!(context_menu::finish_rename(&mut app, Some("Parts"), true)); assert_eq!(app.session.doc.browser_groups[0].name, "Parts"); act(&mut app, &t, "Show/Hide"); - assert_eq!(app.ui.hidden_bodies.len(), 2); + assert_eq!(app.session.visibility.hidden_bodies.len(), 2); act(&mut app, &t, "Ungroup"); assert!(app.session.doc.browser_groups.is_empty()); act(&mut app, &folder, "New Group"); @@ -351,7 +351,7 @@ fn menus_follow_fusion_order() { let origin = context_menu::items(&app, &Target::Origin); order(&origin, &["Show/Hide", "Show All", "Hide Planes", "Hide Axes"]); context_menu::run_item(&mut app, &find(&origin, "Hide Planes"), pos2(0.0, 0.0)); - assert!(["XY", "XZ", "YZ"].iter().all(|p| app.ui.hidden_origin.iter().any(|h| h == p))); + assert!(["XY", "XZ", "YZ"].iter().all(|p| app.session.visibility.hidden_origin.iter().any(|h| h == p))); app.run("select.clear", json!({})).unwrap(); let empty = context_menu::items(&app, &Target::Viewport); order(&empty, &["Pan", "Zoom", "Orbit", "Show All", "Unisolate", "Extrude", "Fillet"]); @@ -367,17 +367,17 @@ fn v_toggles_what_is_selected() { app.run("select.set", json!({"items": [{"type": "body", "name": b}, {"type": "plane", "name": "XY"}, {"type": "feature", "id": sk}]})).unwrap(); crate::browser::set_sketch_visible(&mut app, sk, true); context_menu::toggle_visibility(&mut app); - assert!(app.ui.hidden_bodies.contains(&b) && app.ui.hidden_origin.iter().any(|h| h == "XY")); + assert!(app.session.visibility.hidden_bodies.contains(&b) && app.session.visibility.hidden_origin.iter().any(|h| h == "XY")); assert!(!crate::browser::sketch_visible(&app, sk)); context_menu::toggle_visibility(&mut app); - assert!(app.ui.hidden_bodies.is_empty() && app.ui.hidden_origin.is_empty()); + assert!(app.session.visibility.hidden_bodies.is_empty() && app.session.visibility.hidden_origin.is_empty()); assert!(crate::browser::sketch_visible(&app, sk)); // Components picked in the browser hide their bodies. app.run("component.from_bodies", json!({"bodies": [b.clone()]})).unwrap(); app.run("select.clear", json!({})).unwrap(); app.tree.picked_components = vec![app.session.doc.components[0].id]; context_menu::toggle_visibility(&mut app); - assert_eq!(app.ui.hidden_bodies, vec![b]); + assert_eq!(app.session.visibility.hidden_bodies, vec![b]); assert_eq!(find(&context_menu::items(&app, &Target::Body { name: body(&app) }), "Show/Hide").shortcut, "V"); } diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 6d0d9f6d..55f53c76 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -730,8 +730,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { } "hidden" => { // Bodies hidden in the view. - let h = &ui["ui"]["hidden_bodies"]; - let h = if h.is_null() { &ui["ui"]["hiddenBodies"] } else { h }; + let h = &ui["visibility"]["hidden_bodies"]; if h != v { return Err(format!("hidden bodies: got {h}, want {v}")); } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index c70fefff..244af677 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -192,8 +192,9 @@ fn visible_sketches(app: &SolveApp) -> Vec { .iter() .filter(|s| { app.session.active_sketch == Some(s.feature) - || (!app.ui.hidden_sketches.contains(&s.feature) - && (app.ui.shown_sketches.contains(&s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature)))) + || (!app.session.visibility.hidden_sketches.contains(&s.feature) + && (app.session.visibility.shown_sketches.contains(&s.feature) + || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature)))) }) .map(|s| s.feature) .collect() @@ -210,9 +211,9 @@ fn scene_key(app: &SolveApp) -> u64 { app.cam.up.hash(&mut h); (app.cam.pitch > 0.0).hash(&mut h); app.ui.show_sketches.hash(&mut h); - app.ui.hidden_bodies.hash(&mut h); - app.ui.hidden_sketches.hash(&mut h); - app.ui.shown_sketches.hash(&mut h); + app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_sketches.hash(&mut h); + app.session.visibility.shown_sketches.hash(&mut h); app.ui.hidden_profiles.hash(&mut h); app.session.active_component.hash(&mut h); app.preview.replaced.hash(&mut h); @@ -262,7 +263,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } for b in &st.bodies { - if app.ui.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { + if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { continue; } // With a component active, bodies outside it are drawn faded. @@ -326,7 +327,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { // Section: the outline of each body where the plane cuts it. if let Some((o, n)) = section_plane(app) { let col = c4(tk.text.gamma_multiply(0.9)); - for b in st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)) { + for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)) { for (a, c) in section_segments(&b.mesh(), o, n) { sc.line(a.to_f32(), c.to_f32(), col, 1.8, false); } @@ -367,7 +368,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) { // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solvecraft_engine::geom::Aabb3::EMPTY; - for x in st.bodies.iter().filter(|x| !app.ui.hidden_bodies.contains(&x.name)) { + for x in st.bodies.iter().filter(|x| !app.session.visibility.hidden_bodies.contains(&x.name)) { let bb = x.mesh().bounds(); if bb.is_empty() { continue; @@ -484,7 +485,7 @@ pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> { let creating = !app.ui.show_origin && app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch)); origin_quads(origin_size(app.cam.half_height())) .into_iter() - .filter(|(n, _, _)| creating || !app.ui.hidden_origin.iter().any(|h| h == n)) + .filter(|(n, _, _)| creating || !app.session.visibility.hidden_origin.iter().any(|h| h == n)) .collect() } @@ -492,7 +493,10 @@ pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> { if !app.origin_visible() { return Vec::new(); } - [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().filter(|(n, _)| !app.ui.hidden_origin.iter().any(|h| h == n)).collect() + [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)] + .into_iter() + .filter(|(n, _)| !app.session.visibility.hidden_origin.iter().any(|h| h == n)) + .collect() } /// Construction axes shown, as segments: across the model where the axis passes it (a little @@ -503,7 +507,7 @@ pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> { let bb = solvecraft_engine::view::bounds(&app.session); solvecraft_engine::view::construction_axes(&app.session) .into_iter() - .filter(|(_, n, _, _)| !app.ui.hidden_origin.contains(n)) + .filter(|(_, n, _, _)| !app.session.visibility.hidden_origin.contains(n)) .map(|(_, name, o, d)| { let (mut lo, mut hi) = (-h, h); if !bb.is_empty() { @@ -527,7 +531,7 @@ pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> { pub fn construction_points(app: &SolveApp) -> Vec<(String, Vec3)> { solvecraft_engine::view::construction_points(&app.session) .into_iter() - .filter(|(_, n, _)| !app.ui.hidden_origin.contains(n)) + .filter(|(_, n, _)| !app.session.visibility.hidden_origin.contains(n)) .map(|(_, n, p)| (n, p)) .collect() } @@ -537,7 +541,7 @@ pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> { let h = origin_size(app.cam.half_height()) * 1.2; solvecraft_engine::view::construction_planes(&app.session) .into_iter() - .filter(|(_, n, _)| !app.ui.hidden_origin.contains(n)) + .filter(|(_, n, _)| !app.session.visibility.hidden_origin.contains(n)) .map(|(_, name, pl)| { let (o, x, y) = (pl.origin, pl.x * h, pl.y * h); (name, pl.normal(), [o - x - y, o + x - y, o + x + y, o - x + y]) @@ -625,7 +629,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); app.session.active_sketch.hash(&mut h); app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h); - app.ui.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_bodies.hash(&mut h); app.ui.perspective.hash(&mut h); (proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h); let key = h.finish(); @@ -701,7 +705,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { // Nearest face along the ray (hides what is behind it). let mut bestf: Option<(f64, Hit)> = None; for b in &st.bodies { - if app.ui.hidden_bodies.contains(&b.name) { + if app.session.visibility.hidden_bodies.contains(&b.name) { continue; } // Bodies whose box the ray misses (or enters behind the best hit so far) are skipped. @@ -727,7 +731,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { let mut bestv: Option<(f32, Hit)> = None; let mut beste: Option<(f32, Hit)> = None; for b in &st.bodies { - if app.ui.hidden_bodies.contains(&b.name) { + if app.session.visibility.hidden_bodies.contains(&b.name) { continue; } // Only bodies whose outline on screen is near the cursor can have an edge under it. @@ -1075,8 +1079,8 @@ fn highlight_key(app: &SolveApp) -> u64 { crate::theme::is_dark().hash(&mut h); app.preview.replaced.hash(&mut h); app.origin_visible().hash(&mut h); - app.ui.hidden_origin.hash(&mut h); - app.ui.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_origin.hash(&mut h); + app.session.visibility.hidden_bodies.hash(&mut h); app.cam.half_height().to_bits().hash(&mut h); app.cam.back().x.to_bits().hash(&mut h); h.finish() @@ -1159,7 +1163,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } Sel::Body { name } => { if let Some(b) = st.body(name) - && !app.ui.hidden_bodies.contains(name) + && !app.session.visibility.hidden_bodies.contains(name) { let m = b.mesh(); for f in 0..b.body.face_count() { @@ -1205,7 +1209,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let k = t.hover_face_lift; let lit = |b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name); - for b in st.bodies.iter().filter(|b| lit(b) && !app.ui.hidden_bodies.contains(&b.name)) { + for b in st.bodies.iter().filter(|b| lit(b) && !app.session.visibility.hidden_bodies.contains(&b.name)) { let c = body_rgb(app, &b.name); let m = b.mesh(); for f in 0..b.body.face_count() { @@ -1783,7 +1787,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { } else { let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES); for b in &st.bodies { - if app.ui.hidden_bodies.contains(&b.name) { + if app.session.visibility.hidden_bodies.contains(&b.name) { continue; } let m = b.mesh(); @@ -1845,7 +1849,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let t = Tokens::get(); if app.origin_visible() - && !app.ui.hidden_origin.iter().any(|h| h == "O") + && !app.session.visibility.hidden_origin.iter().any(|h| h == "O") && let Some(o) = proj.to_screen(Vec3::ZERO) { painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110))); @@ -2456,7 +2460,7 @@ mod tests { // It is an axis for dialogs (pattern directions, revolve axes), and hiding it hides it. let (o, dir) = crate::dialogs::axis_of(&app, &Sel::Axis { name: name.clone() }).unwrap(); assert!(o.dist(Vec3::new(0.0, 0.0, 20.0)) < 1e-6 && dir.dist(d) < 1e-6); - app.ui.hidden_origin.push(name); + app.session.visibility.hidden_origin.push(name); assert!(construction_axis_segments(&app).is_empty()); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d83556b9..3d4490b5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -21,8 +21,8 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | UI state, viewport rect, camera, active tool/dialog, hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | -| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, `dark`, …) | `dark: false` switches to the light theme | +| `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, 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.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 6fa8d115..c6a996c0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,7 +51,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app: | `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. | | `execute` | `command`, `params?` | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. | | `batch` | `commands: [{command, params}]`, `rollback?` (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. | -| `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), sketches (curves, points, constraints, profiles, DOF, solve status), selection, undo depth. | +| `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}`). | | `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 features that now fail. |