From 9a9caa651194ddd1782fbb20b9e54d443537d92d Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 02:11:36 +0000 Subject: [PATCH] Browser tree: components with an activation radio (others drawn faded), ground pin, groups in Bodies/Sketches/Construction (new, from selection, rename, ungroup, kept in the design: browser.group/ungroup/rename_group/move/order), drag and drop to reorder, into and out of groups and into another component, Ctrl/Shift multi-select, hover lights the bodies, folds remembered; occurrence Move/Copy with a gizmo and Capture Position/Revert; the viewport shows bodies where their occurrences place them Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 21 + crates/engine/src/cmd/browser.rs | 186 +++++++- crates/ui-egui/src/browser.rs | 724 ++++++++++++++++++++++------- crates/ui-egui/src/context_menu.rs | 26 +- crates/ui-egui/src/lib.rs | 11 + crates/ui-egui/src/menu_tests.rs | 36 ++ crates/ui-egui/src/viewport.rs | 9 +- 7 files changed, 839 insertions(+), 174 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index edd0719d..5be74627 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -807,6 +807,25 @@ pub struct Document { /// Bodies moved into another component than their feature's (by body name). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub body_components: std::collections::BTreeMap, + /// Browser groups (folders of bodies, sketches or construction planes). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub browser_groups: Vec, + /// Browser item order per component folder (`"/"` → item keys). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub browser_order: std::collections::BTreeMap>, +} + +/// A browser group: items of one folder (`bodies`, `sketches` or `construction`) of a +/// component, by key (body name, or the feature id of a sketch or construction plane). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BrowserGroup { + pub id: u64, + pub name: String, + #[serde(default)] + pub component: u64, + pub folder: String, + #[serde(default)] + pub items: Vec, } fn default_format() -> String { @@ -839,6 +858,8 @@ impl Document { materials: Default::default(), favorites: Default::default(), param_comments: Default::default(), + browser_groups: Vec::new(), + browser_order: Default::default(), } } diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 13c81a29..1068a0c0 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -1,4 +1,8 @@ -//! Browser commands: renaming bodies, moving a sketch to another plane. +//! Browser commands: renaming bodies, moving a sketch to another plane, and the browser's +//! groups and item order (kept in the design, so they are saved and undone like features). +//! +//! Browser items are keyed per component folder (`bodies`, `sketches`, `construction`): a body +//! by name, a sketch or construction plane by feature id. use serde_json::{Value, json}; @@ -13,8 +17,149 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch) .icon("plane") .params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"), + CommandSpec::new("browser.group", "New Group", group) + .icon("folder") + .params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"), + CommandSpec::new("browser.ungroup", "Ungroup", ungroup).params("group: id (its items go back to the folder)"), + CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"), + CommandSpec::new("browser.move", "Move Browser Items", move_items) + .params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"), + CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"), ]; +const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"]; + +fn folder_of(p: &Value, cmd: &str) -> Result { + match str_(p, "folder") { + Some(f) if FOLDERS.contains(&f) => Ok(f.to_string()), + _ => Err(bad(cmd, "`folder` must be bodies, sketches or construction")), + } +} + +fn component_of(s: &Session, p: &Value, cmd: &str) -> Result { + let c = p.get("component").and_then(Value::as_u64).unwrap_or(0); + if c != 0 && !s.doc.components.iter().any(|x| x.id == c) { + return Err(bad(cmd, format!("no component {c}"))); + } + Ok(c) +} + +fn keys(p: &Value, k: &str, cmd: &str) -> Result> { + let v = match p.get(k) { + Some(Value::Array(a)) => { + a.iter().map(|x| x.as_str().map(str::to_string).or_else(|| x.as_u64().map(|n| n.to_string()))).collect::>>() + } + None => Some(Vec::new()), + _ => None, + }; + let v = v.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys")))?; + if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || x.len() > 256) { + return Err(bad(cmd, format!("`{k}` has bad keys"))); + } + Ok(v) +} + +fn group_id(s: &Session, p: &Value, cmd: &str) -> Result { + let id = p.get("group").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`group` must be a group id"))?; + if s.doc.browser_groups.iter().any(|g| g.id == id) { Ok(id) } else { Err(bad(cmd, format!("no group {id}"))) } +} + +fn group(s: &mut Session, p: &Value) -> Result { + let cmd = "browser.group"; + let folder = folder_of(p, cmd)?; + let component = component_of(s, p, cmd)?; + let items = keys(p, "items", cmd)?; + let d = s.doc_mut(); + let id = d.browser_groups.iter().map(|g| g.id).max().unwrap_or(0) + 1; + let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) if n.len() <= 128 => n.to_string(), + Some(_) => return Err(bad(cmd, "`name` is too long")), + None => format!("Group{}", d.browser_groups.iter().filter(|g| g.folder == folder).count() + 1), + }; + for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) { + g.items.retain(|k| !items.contains(k)); + } + d.browser_groups.push(solvecraft_doc::BrowserGroup { id, name: name.clone(), component, folder, items }); + Ok(json!({ "group": id, "name": name })) +} + +fn ungroup(s: &mut Session, p: &Value) -> Result { + let id = group_id(s, p, "browser.ungroup")?; + s.doc_mut().browser_groups.retain(|g| g.id != id); + Ok(json!({ "group": id })) +} + +fn rename_group(s: &mut Session, p: &Value) -> Result { + let cmd = "browser.rename_group"; + let id = group_id(s, p, cmd)?; + let name = + str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + if let Some(g) = s.doc_mut().browser_groups.iter_mut().find(|g| g.id == id) { + g.name = name.to_string(); + } + Ok(json!({ "group": id, "name": name })) +} + +fn move_items(s: &mut Session, p: &Value) -> Result { + let cmd = "browser.move"; + let folder = folder_of(p, cmd)?; + let component = component_of(s, p, cmd)?; + let items = keys(p, "items", cmd)?; + if items.is_empty() { + return Err(bad(cmd, "`items` must list item keys")); + } + let to = match p.get("group") { + Some(Value::Null) | None => None, + Some(_) => Some(group_id(s, p, cmd)?), + }; + if let Some(g) = to + && !s.doc.browser_groups.iter().any(|x| x.id == g && x.component == component && x.folder == folder) + { + return Err(bad(cmd, "that group is in another folder")); + } + let before = str_(p, "before").map(str::to_string); + let d = s.doc_mut(); + for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) { + g.items.retain(|k| !items.contains(k)); + if Some(g.id) == to { + let at = before.as_ref().and_then(|b| g.items.iter().position(|k| k == b)).unwrap_or(g.items.len()); + for (i, k) in items.iter().enumerate() { + g.items.insert(at + i, k.clone()); + } + } + } + Ok(json!({ "items": items, "group": to })) +} + +fn order(s: &mut Session, p: &Value) -> Result { + let cmd = "browser.order"; + let folder = folder_of(p, cmd)?; + let component = component_of(s, p, cmd)?; + let order = keys(p, "order", cmd)?; + let key = format!("{component}/{folder}"); + let d = s.doc_mut(); + if order.is_empty() { + d.browser_order.remove(&key); + } else { + d.browser_order.insert(key, order); + } + Ok(json!({})) +} + +/// Rename an item key in the browser's groups and orders of a folder. +fn rename_key(d: &mut solvecraft_doc::Document, folder: &str, old: &str, new: &str) { + for g in d.browser_groups.iter_mut().filter(|g| g.folder == folder) { + for k in g.items.iter_mut().filter(|k| *k == old) { + *k = new.to_string(); + } + } + for (_, v) in d.browser_order.iter_mut().filter(|(k, _)| k.ends_with(&format!("/{folder}"))) { + for k in v.iter_mut().filter(|k| *k == old) { + *k = new.to_string(); + } + } +} + /// Replace every string equal to `old` with `new`; true when something changed. fn replace_str(v: &mut Value, old: &str, new: &str, depth: usize) -> bool { if depth > 64 { @@ -77,6 +222,7 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { if let Some(c) = d.body_components.remove(&old) { d.body_components.insert(name.to_string(), c); } + rename_key(d, "bodies", &old, name); Ok(json!({"body": old, "name": name})) } @@ -153,4 +299,42 @@ mod tests { assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err()); assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err()); } + + #[test] + fn groups_hold_items_and_follow_renames() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]})); + let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()]; + let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}))["group"].as_u64().unwrap(); + assert_eq!(s.doc.browser_groups[0].name, "Group1"); + run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()], "group": g, "before": a.clone()})); + assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), a.clone()]); + run(&mut s, "browser.rename_group", json!({"group": g, "name": "Fasteners"})); + run(&mut s, "body.rename", json!({"body": a, "name": "Bolt"})); + assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), "Bolt".to_string()]); + // Out of the group, then a folder order; saved with the design. + run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()]})); + assert_eq!(s.doc.browser_groups[0].items, vec!["Bolt".to_string()]); + run(&mut s, "browser.order", json!({"folder": "bodies", "order": [b.clone(), "Bolt"]})); + let saved: solvecraft_doc::Document = serde_json::from_str(&serde_json::to_string(&*s.doc).unwrap()).unwrap(); + assert_eq!(saved.browser_groups, s.doc.browser_groups); + assert_eq!(saved.browser_order["0/bodies"], vec![b, "Bolt".to_string()]); + run(&mut s, "browser.ungroup", json!({"group": g})); + assert!(s.doc.browser_groups.is_empty()); + // Hostile input is refused. + for (c, p) in [ + ("browser.group", json!({"folder": "nope"})), + ("browser.group", json!({"folder": "bodies", "component": 99})), + ("browser.group", json!({"folder": "bodies", "items": "x"})), + ("browser.move", json!({"folder": "bodies", "items": []})), + ("browser.move", json!({"folder": "bodies", "items": ["x"], "group": 42})), + ("browser.ungroup", json!({"group": -1})), + ("browser.rename_group", json!({"group": g, "name": "x"})), + ] { + assert!(s.execute(c, &p).is_err(), "{c} {p}"); + } + run(&mut s, "UndoCommand", json!({})); + assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes"); + } } diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 0d8712c5..daa453ec 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -78,19 +78,97 @@ pub fn redefine_sketch(app: &mut SolveApp, id: u64) { app.tree.redefine = Some(id); } -pub fn group_bodies(_app: &SolveApp, _id: u64) -> Vec { - Vec::new() +fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::BrowserGroup> { + app.session.doc.browser_groups.iter().find(|g| g.id == id) } -pub fn group_items(_app: &SolveApp, _id: u64) -> Vec { - Vec::new() +/// The bodies in a group (none for sketch and plane groups). +pub fn group_bodies(app: &SolveApp, id: u64) -> Vec { + let st = app.session.model.state(); + find_group(app, id) + .filter(|g| g.folder == "bodies") + .map(|g| g.items.iter().filter(|b| st.body(b).is_some()).cloned().collect()) + .unwrap_or_default() } -pub fn group_name(_app: &SolveApp, _id: u64) -> Option { - None +pub fn group_name(app: &SolveApp, id: u64) -> Option { + find_group(app, id).map(|g| g.name.clone()) } -pub fn group_action(_app: &mut SolveApp, _action: &str, _p: &Value) {} +/// The menu of a group row. +pub fn group_items(app: &SolveApp, id: u64) -> Vec { + let Some(g) = find_group(app, id) else { return Vec::new() }; + let mut v = vec![ + Item::action("ui.rename", "Rename", "").with(json!({ "group": id })), + Item::action("ui.ungroup", "Ungroup", "folder").with(json!({ "group": id })), + ]; + 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)); + v.push(Item::sep()); + v.push( + Item::action(if hidden { "ui.show" } else { "ui.hide" }, if hidden { "Show" } else { "Hide" }, "eye").with(json!({ "bodies": bodies })), + ); + v.push(Item::action("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies }))); + } + v +} + +/// The menu of a folder row (Bodies, Sketches, Construction). +pub fn folder_items(app: &SolveApp, component: u64, folder: &str) -> Vec { + let sel: Vec = selected_keys(app, component, folder); + let mut v = vec![Item::action("ui.newGroup", "New Group", "folder").with(json!({ "component": component, "folder": folder }))]; + if sel.len() > 1 { + v.push(Item::action("ui.groupSelected", "Group Selected", "folder").with(json!({ "component": component, "folder": folder, "items": sel }))); + } + if folder == "bodies" { + v.push(Item::sep()); + v.push(Item::action("ui.showAll", "Show All Bodies", "eye")); + } + v +} + +/// Selected browser items of the folder of a body or sketch, when more than one (for Group +/// Selected in their menus). +pub fn selection_group(app: &SolveApp, target: &Target) -> Option { + let (comp, folder) = match target { + Target::Body { name } => { + let st = app.session.model.state(); + let b = st.body(name)?; + (app.session.doc.body_component(name, b.feature), "bodies") + } + Target::Sketch { id } => (app.session.doc.feature(*id)?.component, "sketches"), + _ => return None, + }; + let sel = selected_keys(app, comp, folder); + (sel.len() > 1).then(|| json!({ "component": comp, "folder": folder, "items": sel })) +} + +/// Group actions from the menus. +pub fn group_action(app: &mut SolveApp, action: &str, p: &Value) { + match action { + "ui.newGroup" | "ui.groupSelected" => { + let folder = p.get("folder").and_then(Value::as_str).unwrap_or("bodies"); + let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0); + let items = p.get("items").cloned().unwrap_or(json!([])); + if let Ok(r) = app.run("browser.group", json!({ "component": comp, "folder": folder, "items": items })) + && let Some(g) = r.get("group").and_then(Value::as_u64) + { + app.tree.collapsed.remove(&format!("c{comp}/{folder}")); + app.tree.collapsed.remove(&format!("g{g}")); + } + } + "ui.ungroup" => { + if let Some(g) = p.get("group") { + let _ = app.run("browser.ungroup", json!({ "group": g })); + } + } + "ui.renameGroup" => { + let _ = app.run("browser.rename_group", p.clone()); + } + _ => {} + } +} // --------------------------------------------------------------------------------------------- // Drawing @@ -129,11 +207,13 @@ struct RowResp { eye: bool, radio: bool, hovered: bool, + /// The row's response (drag and drop). + resp: Option, } fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let t = Tokens::get(); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); let mut out = RowResp { rect: Some(r), hovered: resp.hovered(), ..Default::default() }; if row.selected { ui.painter().rect_filled(r, 3.0, t.accent_soft); @@ -194,6 +274,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { badge(ui.painter(), br, b, t.text_dim); bx += 15.0; } + out.resp = Some(resp.clone()); out.clicked = resp.clicked(); out.double = resp.double_clicked(); if resp.secondary_clicked() { @@ -289,6 +370,7 @@ fn toggle(app: &mut SolveApp, key: &str, default: bool) { struct Actions { menu: Option<(Pos2, Target)>, hover_bodies: Vec, + drop: Option, } pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { @@ -302,12 +384,18 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim)); ui.add_space(4.0); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - component_rows(app, ui, 0, 0, &mut acts); - }); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE }) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + component_rows(app, ui, 0, 0, &mut acts); + }); }); app.viewport.hover_bodies = acts.hover_bodies; + if let Some(d) = acts.drop { + apply_drop(app, d); + } if let Some((at, target)) = acts.menu { crate::context_menu::open_for(app, at, target); } @@ -383,6 +471,12 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, if let Some(p) = r.secondary { acts.menu = Some((p, Target::Component { id })); } + // Bodies and sketches dropped on a component move into it. + if let Some(resp) = &r.resp + && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != id && d.folder != "construction", DropMark::Box) + { + acts.drop = Some(DropAction::Component { to: id, folder: d.folder, keys: d.keys.clone() }); + } if !open { return; } @@ -392,9 +486,9 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, origin_rows(app, ui, d); } let _ = t; - body_rows(app, ui, id, d, acts); - sketch_rows(app, ui, id, d, acts); - plane_rows(app, ui, id, d); + for folder in ["bodies", "sketches", "construction"] { + folder_rows(app, ui, id, folder, d, acts); + } let children: Vec = app.session.doc.components.iter().filter(|c| c.parent == id).map(|c| c.id).collect(); for c in children { component_rows(app, ui, c, d, acts); @@ -491,176 +585,398 @@ fn pick_from_browser(app: &mut SolveApp, x: Sel) { let _ = app.run("select.set", json!({ "items": [x] })); } -/// A folder row (Bodies, Sketches, Construction) with an eye for everything in it; true when -/// it is open. -fn folder(app: &mut SolveApp, ui: &mut egui::Ui, key: &str, label: &str, depth: usize, visible: Option) -> (bool, RowResp) { - let open = is_open(app, key, true); - let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), eye: visible, icon: "folder", label, ..Default::default() }); - if r.fold || r.clicked { - toggle(app, key, true); - } - (open, r) +/// One item of a component folder. +struct Entry { + /// The browser key (body name, or the feature id of a sketch or plane). + key: String, + /// The key in [`TreeState`] row order (`b:`, `s:`, `p:`). + row_key: String, + label: String, + icon: &'static str, + visible: bool, + selected: bool, + color: Option, + locked: bool, + sel: Option, + target: Option, + /// Bodies lit when the row is hovered. + bodies: Vec, } -fn body_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize, acts: &mut Actions) { - let st = app.session.model.state(); - let doc = app.session.doc.clone(); - let bodies: Vec = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp).map(|b| b.name.clone()).collect(); - if bodies.is_empty() { +/// What is being dragged in the tree: items of one component folder. +#[derive(Clone, Debug)] +struct Drag { + comp: u64, + folder: &'static str, + keys: Vec, +} + +/// The items of a component folder, in model order. +fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { + let t = Tokens::get(); + let doc = &app.session.doc; + let sel = &app.session.selection; + match folder { + "bodies" => app + .session + .model + .state() + .bodies + .iter() + .filter(|b| doc.body_component(&b.name, b.feature) == comp) + .map(|b| Entry { + key: b.name.clone(), + row_key: format!("b:{}", b.name), + label: b.name.clone(), + icon: "body", + visible: !app.ui.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), + sel: Some(Sel::Body { name: b.name.clone() }), + target: Some(Target::Body { name: b.name.clone() }), + bodies: vec![b.name.clone()], + }) + .collect(), + "sketches" => doc + .features + .iter() + .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. })) + .map(|f| { + let active = app.session.active_sketch == Some(f.id); + Entry { + key: f.id.to_string(), + row_key: format!("s:{}", f.id), + label: if active { format!("{} (editing)", f.name) } else { f.name.clone() }, + icon: "sketch", + visible: sketch_visible(app, f.id), + selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)), + color: active.then_some(t.sketch_accent), + locked: false, + sel: Some(Sel::Feature { id: f.id }), + target: Some(Target::Sketch { id: f.id }), + bodies: Vec::new(), + } + }) + .collect(), + _ => doc + .features + .iter() + .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. })) + .map(|f| Entry { + key: f.id.to_string(), + row_key: format!("p:{}", f.id), + label: f.name.clone(), + icon: "plane", + visible: !app.ui.hidden_origin.contains(&f.name), + selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)), + color: None, + locked: false, + sel: Some(Sel::Plane { name: f.name.clone() }), + target: None, + bodies: Vec::new(), + }) + .collect(), + } +} + +/// A folder's groups (with their items) and the items in no group, in browser order. +fn arrange(app: &SolveApp, comp: u64, folder: &str, all: Vec) -> (Vec<(solvecraft_engine::doc::BrowserGroup, Vec)>, Vec) { + let doc = &app.session.doc; + let groups: Vec = + doc.browser_groups.iter().filter(|g| g.component == comp && g.folder == folder).cloned().collect(); + let mut rest: Vec> = all.into_iter().map(Some).collect(); + let mut take = |key: &str| rest.iter_mut().find(|e| e.as_ref().is_some_and(|e| e.key == key)).and_then(Option::take); + let grouped: Vec<_> = groups + .into_iter() + .map(|g| { + let items = g.items.iter().filter_map(|k| take(k)).collect(); + (g, items) + }) + .collect(); + let mut loose: Vec = rest.into_iter().flatten().collect(); + if let Some(order) = doc.browser_order.get(&format!("{comp}/{folder}")) { + let pos = |k: &str| order.iter().position(|x| x == k).unwrap_or(usize::MAX); + loose.sort_by_key(|e| pos(&e.key)); + } + (grouped, loose) +} + +/// Show or hide items of a folder. +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); + if !on { + app.ui.hidden_bodies.push(e.key.clone()); + } + } + "sketches" => { + if let Ok(id) = e.key.parse() { + set_sketch_visible(app, id, on); + } + } + _ => { + app.ui.hidden_origin.retain(|p| *p != e.label); + if !on { + app.ui.hidden_origin.push(e.label.clone()); + } + } + } + } +} + +fn folder_label(folder: &str) -> &'static str { + match folder { + "bodies" => "Bodies", + "sketches" => "Sketches", + _ => "Construction", + } +} + +fn folder_static(folder: &str) -> &'static str { + match folder { + "bodies" => "bodies", + "sketches" => "sketches", + _ => "construction", + } +} + +/// A component's folder: its row, its groups and its items. Rows are drag sources; items, +/// groups and the folder row are drop targets (reorder, into a group, out of groups). +fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, depth: usize, acts: &mut Actions) { + let t = Tokens::get(); + let all = entries(app, comp, folder); + if all.is_empty() { return; } - let key = format!("c{comp}/bodies"); - let reveal = app.tree.reveal.as_ref().is_some_and(|b| bodies.contains(b)); - if reveal { + let fstatic = folder_static(folder); + let key = format!("c{comp}/{folder}"); + if let Some(r) = app.tree.reveal.clone() + && folder == "bodies" + && all.iter().any(|e| e.key == r) + { app.tree.collapsed.remove(&key); app.tree.collapsed.remove(&format!("c{comp}")); + for g in app.session.doc.browser_groups.iter().filter(|g| g.items.contains(&r)) { + app.tree.collapsed.remove(&format!("g{}", g.id)); + } + } + let open = is_open(app, &key, true); + let any_visible = if folder == "sketches" { app.ui.show_sketches } else { all.iter().any(|e| e.visible) }; + let r = draw_row( + ui, + ui.id().with(&key), + &Row { depth, fold: Some(open), eye: Some(any_visible), icon: "folder", label: folder_label(folder), ..Default::default() }, + ); + if r.fold || r.clicked { + toggle(app, &key, true); } - let any_visible = bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b)); - let (open, r) = folder(app, ui, &key, "Bodies", depth, Some(any_visible)); if r.eye { - if any_visible { - app.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::>()); + if folder == "sketches" { + app.ui.show_sketches = !app.ui.show_sketches; } else { - app.ui.hidden_bodies.retain(|b| !bodies.contains(b)); + let refs: Vec<&Entry> = all.iter().collect(); + set_visible(app, folder, &refs, !any_visible); } } if r.hovered { - acts.hover_bodies = bodies.clone(); + acts.hover_bodies = all.iter().flat_map(|e| e.bodies.clone()).collect(); + } + if let Some(p) = r.secondary { + acts.menu = Some((p, Target::Folder { component: comp, folder: fstatic.to_string() })); + } + // Dropping on the folder row takes items out of their groups, to the end. + if let Some(resp) = &r.resp + && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == fstatic, DropMark::Box) + { + acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: None, before: None }); } if !open { return; } - for b in &bodies { - let visible = !app.ui.hidden_bodies.contains(b); - let selected = app.session.selection.iter().any(|x| matches!(x, Sel::Body { name } if name == b)); - let locked = app.ui.locked_bodies.contains(b); - let badges: &[&str] = if locked { &["lock"] } else { &[] }; - let key = format!("b:{b}"); - app.tree.order.push(key.clone()); + let (groups, loose) = arrange(app, comp, folder, all); + let loose_keys: Vec = loose.iter().map(|e| e.key.clone()).collect(); + for (g, items) in &groups { + let gkey = format!("g{}", g.id); + let gopen = is_open(app, &gkey, true); + let gvis = items.is_empty() || items.iter().any(|e| e.visible); + let label = format!("{} ({})", g.name, items.len()); let r = draw_row( ui, - ui.id().with(("body", b)), - &Row { depth: depth + 1, eye: Some(visible), icon: "body", label: b, selected, dim: !visible, badges, ..Default::default() }, + ui.id().with(&gkey), + &Row { depth: depth + 1, fold: Some(gopen), eye: Some(gvis), icon: "folder", label: &label, dim: !gvis, ..Default::default() }, ); - if app.tree.reveal.as_deref() == Some(b.as_str()) - && let Some(rect) = r.rect - { - ui.scroll_to_rect(rect, Some(egui::Align::Center)); - app.tree.reveal = None; + if r.fold || r.clicked { + toggle(app, &gkey, true); } if r.eye { - if visible { - app.ui.hidden_bodies.push(b.clone()); - } else { - app.ui.hidden_bodies.retain(|n| n != b); - } - } - if r.clicked { - click_select(app, ui, key, Some(Sel::Body { name: b.clone() })); + let refs: Vec<&Entry> = items.iter().collect(); + set_visible(app, folder, &refs, !gvis); } if r.hovered { - acts.hover_bodies = vec![b.clone()]; + acts.hover_bodies = items.iter().flat_map(|e| e.bodies.clone()).collect(); } if let Some(p) = r.secondary { - if !selected { - let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b}]})); + acts.menu = Some((p, Target::Group { id: g.id })); + } + if let Some(resp) = &r.resp + && let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == fstatic, DropMark::Box) + { + acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: Some(g.id), before: None }); + } + if gopen { + for e in items { + entry_row(app, ui, comp, fstatic, e, depth + 2, Some(g.id), &loose_keys, acts); } - acts.menu = Some((p, Target::Body { name: b.clone() })); } } + for e in &loose { + entry_row(app, ui, comp, fstatic, e, depth + 1, None, &loose_keys, acts); + } + let _ = t; } -fn sketch_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize, acts: &mut Actions) { - let t = Tokens::get(); - let sketches: Vec<(u64, String)> = app - .session - .doc - .features - .iter() - .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. })) - .map(|f| (f.id, f.name.clone())) - .collect(); - if sketches.is_empty() { - return; +/// One item row of a folder. +#[allow(clippy::too_many_arguments)] +fn entry_row( + app: &mut SolveApp, + ui: &mut egui::Ui, + comp: u64, + folder: &'static str, + e: &Entry, + depth: usize, + group: Option, + loose: &[String], + acts: &mut Actions, +) { + app.tree.order.push(e.row_key.clone()); + let badges: &[&str] = if e.locked { &["lock"] } else { &[] }; + let r = draw_row( + ui, + ui.id().with(("item", &e.row_key)), + &Row { + depth, + eye: Some(e.visible), + icon: e.icon, + label: &e.label, + selected: e.selected, + dim: !e.visible, + color: e.color, + badges, + ..Default::default() + }, + ); + if folder == "bodies" + && app.tree.reveal.as_deref() == Some(e.key.as_str()) + && let Some(rect) = r.rect + { + ui.scroll_to_rect(rect, Some(egui::Align::Center)); + app.tree.reveal = None; } - let key = format!("c{comp}/sketches"); - let (open, r) = folder(app, ui, &key, "Sketches", depth, Some(app.ui.show_sketches)); if r.eye { - app.ui.show_sketches = !app.ui.show_sketches; + set_visible(app, folder, &[e], !e.visible); } - if !open { - return; - } - for (id, name) in sketches { - let active = app.session.active_sketch == Some(id); - let visible = sketch_visible(app, id); - let selected = active || app.session.selection.iter().any(|x| matches!(x, Sel::Feature { id: f } if *f == id)); - let label = if active { format!("{name} (editing)") } else { name.clone() }; - let key = format!("s:{id}"); - app.tree.order.push(key.clone()); - let r = draw_row( - ui, - ui.id().with(("sketch", id)), - &Row { - depth: depth + 1, - eye: Some(visible), - icon: "sketch", - label: &label, - selected, - dim: !visible, - color: active.then_some(t.sketch_accent), - ..Default::default() - }, - ); - if r.eye { - set_sketch_visible(app, id, !visible); - } - if r.double { + if r.double && folder == "sketches" { + if let Ok(id) = e.key.parse() { app.edit_sketch(id); - } else if r.clicked { - click_select(app, ui, key, Some(Sel::Feature { id })); } - if let Some(p) = r.secondary { - acts.menu = Some((p, Target::Sketch { id })); + } else if r.clicked { + if folder == "construction" { + if let Some(s) = e.sel.clone() { + pick_from_browser(app, s); + } + } else { + click_select(app, ui, e.row_key.clone(), e.sel.clone()); } } + if r.hovered && !e.bodies.is_empty() { + acts.hover_bodies = e.bodies.clone(); + } + if let Some(p) = r.secondary + && let Some(t) = e.target.clone() + { + if !e.selected + && let Some(s) = &e.sel + { + let _ = app.run("select.set", json!({ "items": [s] })); + } + acts.menu = Some((p, t)); + } + let Some(resp) = &r.resp else { return }; + // Drag: the selected items of this folder when this one is selected, else just this one. + if resp.drag_started() { + let keys: Vec = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] }; + resp.dnd_set_drag_payload(Drag { comp, folder, keys }); + } + if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key), DropMark::Above) { + acts.drop = Some(match group { + Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), group: Some(g), before: Some(e.key.clone()) }, + None => { + let mut order: Vec = loose.iter().filter(|k| !d.keys.contains(k)).cloned().collect(); + let at = order.iter().position(|k| *k == e.key).unwrap_or(order.len()); + for (i, k) in d.keys.iter().enumerate() { + order.insert(at + i, k.clone()); + } + DropAction::Order { comp, folder, keys: d.keys.clone(), order } + } + }); + } } -fn plane_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize) { - let planes: Vec = app - .session - .doc - .features - .iter() - .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. })) - .map(|f| f.name.clone()) - .collect(); - if planes.is_empty() { - return; +/// Keys of the selected items of a component folder. +fn selected_keys(app: &SolveApp, comp: u64, folder: &str) -> Vec { + entries(app, comp, folder).into_iter().filter(|e| e.selected).map(|e| e.key).collect() +} + +#[derive(Clone, Copy, PartialEq)] +enum DropMark { + /// A line above the row: the items go before it. + Above, + /// A box around the row: the items go into it. + Box, +} + +/// While a matching payload hovers the row, mark where it would land; on release, the payload. +fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> bool, mark: DropMark) -> Option> { + let t = Tokens::get(); + let hovering = resp.dnd_hover_payload::().filter(|d| accept(d)); + if hovering.is_some() { + let r = resp.rect; + match mark { + DropMark::Above => ui.painter().hline(r.x_range(), r.top(), Stroke::new(2.0, t.accent)), + DropMark::Box => ui.painter().rect_stroke(r.shrink(0.5), 3.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside), + }; } - let key = format!("c{comp}/construction"); - let (open, _) = folder(app, ui, &key, "Construction", depth, None); - if !open { - return; - } - for p in planes { - let visible = !app.ui.hidden_origin.contains(&p); - let sel = Sel::Plane { name: p.clone() }; - let selected = app.session.selection.contains(&sel); - let r = draw_row( - ui, - ui.id().with(("plane", &p)), - &Row { depth: depth + 1, eye: Some(visible), icon: "plane", label: &p, selected, dim: !visible, ..Default::default() }, - ); - if r.eye { - if visible { - app.ui.hidden_origin.push(p.clone()); - } else { - app.ui.hidden_origin.retain(|h| *h != p); - } + resp.dnd_release_payload::().filter(|d| accept(d)) +} + +/// A drop, applied after the tree is drawn. +enum DropAction { + /// Into a group (before an item of it), or out of groups. + Move { comp: u64, folder: &'static str, keys: Vec, group: Option, before: Option }, + /// Out of groups, in this order of the folder's loose items. + Order { comp: u64, folder: &'static str, keys: Vec, order: Vec }, + /// Into another component (bodies and sketches). + Component { to: u64, folder: &'static str, keys: Vec }, +} + +fn apply_drop(app: &mut SolveApp, d: DropAction) { + match d { + DropAction::Move { comp, folder, keys, group, before } => { + let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": keys, "group": group, "before": before })); } - if r.clicked { - pick_from_browser(app, sel); + DropAction::Order { comp, folder, keys, order } => { + let _ = app.run("browser.move", json!({ "component": comp, "folder": folder, "items": keys })); + let _ = app.run("browser.order", json!({ "component": comp, "folder": folder, "order": order })); + } + DropAction::Component { to, folder, keys } => { + let _ = match folder { + "bodies" => app.run("component.move_bodies", json!({ "bodies": keys, "component": to })), + "sketches" => app.run("component.move_sketches", json!({ "sketches": keys, "component": to })), + _ => Ok(serde_json::Value::Null), + }; } } } @@ -676,29 +992,36 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) { let mut open = true; let mut action: Option<&str> = None; let before = (m.translate, m.angle_deg); - egui::Window::new(format!("Move: {name}")).id(egui::Id::new("sc_occ_move")).open(&mut open).resizable(false).collapsible(false).show(ctx, |ui| { - egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { - for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() { - ui.label(*axis); - if let Some(v) = m.translate.get_mut(k) { - ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + egui::Window::new(format!("Move: {name}")) + .id(egui::Id::new("sc_occ_move")) + .default_pos(panel_pos(app)) + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() { + ui.label(*axis); + if let Some(v) = m.translate.get_mut(k) { + ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + } + ui.end_row(); } + ui.label("Z angle"); + ui.add(egui::DragValue::new(&mut m.angle_deg).speed(1.0).suffix(" deg")); ui.end_row(); - } - ui.label("Z angle"); - ui.add(egui::DragValue::new(&mut m.angle_deg).speed(1.0).suffix(" deg")); - ui.end_row(); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Capture Position").clicked() { + action = Some("capture"); + } + if ui.button("Revert").clicked() { + action = Some("revert"); + } + }); }); - ui.add_space(6.0); - ui.horizontal(|ui| { - if ui.button("Capture Position").clicked() { - action = Some("capture"); - } - if ui.button("Revert").clicked() { - action = Some("revert"); - } - }); - }); + gizmo(app, ctx, &mut m); if (m.translate, m.angle_deg) != before { // The pending move is the typed offset from where the occurrence is now. let _ = app.session.execute("AsBuiltPositionsCmd", &json!({})); @@ -724,6 +1047,78 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) { } } +/// Where the browser's panels open: the viewport's top left corner. +fn panel_pos(app: &SolveApp) -> Pos2 { + app.viewport.rect.map(|r| r.left_top() + vec2(16.0, 16.0)).unwrap_or(pos2(270.0, 140.0)) +} + +/// Where an occurrence's origin is now (with its pending move). +fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option { + let o = app.session.doc.occurrences.iter().find(|o| o.id == occurrence)?; + let parent = app.session.doc.component_transform(o.parent); + let own = app.session.pending_moves.get(&occurrence).copied().unwrap_or(o.transform); + Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO)) +} + +/// Screen length of the gizmo's arrows. +const GIZMO_PX: f32 = 80.0; + +/// Arrows along X, Y and Z at the occurrence's origin: dragging one moves it along that axis. +fn gizmo(app: &mut SolveApp, ctx: &egui::Context, m: &mut OccMove) { + use solvecraft_engine::geom::Vec3; + let t = Tokens::get(); + let (Some(rect), Some(o)) = (app.viewport.rect, occurrence_origin(app, m.occurrence)) else { return }; + let proj = crate::viewport::projection(app, rect); + let Some(p0) = proj.to_screen(o) else { return }; + if !rect.contains(p0) { + return; + } + let axes = [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)]; + // Per axis: the screen direction and pixels per millimetre. + let mut arms = Vec::new(); + for (k, (a, col)) in axes.iter().enumerate() { + let Some(p1) = proj.to_screen(o + *a) else { continue }; + let d = p1 - p0; + let px_per_mm = d.length(); + if px_per_mm < 1e-6 || !px_per_mm.is_finite() { + continue; + } + arms.push((k, d / px_per_mm, px_per_mm, *col)); + } + let bbox = Rect::from_center_size(p0, vec2(2.0 * GIZMO_PX + 30.0, 2.0 * GIZMO_PX + 30.0)).intersect(rect); + egui::Area::new(egui::Id::new("sc_occ_gizmo")).fixed_pos(bbox.min).order(egui::Order::Middle).show(ctx, |ui| { + let (_, _) = ui.allocate_exact_size(bbox.size(), Sense::hover()); + let painter = ui.painter_at(bbox); + for (k, dir, px_per_mm, col) in &arms { + // An axis seen end-on has no arrow to drag. + let len = GIZMO_PX * dir.length().min(1.0); + let tip = p0 + *dir * len; + let handle = + Rect::from_center_size(p0 + *dir * (len * 0.6), vec2(len * 0.8, len * 0.8)).intersect(Rect::from_two_pos(p0, tip).expand(9.0)); + let resp = ui.interact(handle, ui.id().with(("arm", *k)), Sense::drag()); + let lit = resp.hovered() || resp.dragged(); + let w = if lit { 4.0 } else { 2.5 }; + painter.line_segment([p0 + *dir * 10.0, tip], Stroke::new(w, *col)); + let n = vec2(-dir.y, dir.x); + painter.add(egui::Shape::convex_polygon(vec![tip + *dir * 10.0, tip + n * 5.5, tip - n * 5.5], *col, Stroke::NONE)); + if resp.dragged() { + let mm = resp.drag_delta().dot(*dir) / px_per_mm; + if let Some(v) = m.translate.get_mut(*k) + && mm.is_finite() + { + *v += f64::from(mm); + } + } + if resp.drag_stopped() + && let Some(v) = m.translate.get_mut(*k) + { + *v = (*v * 100.0).round() / 100.0; + } + } + painter.circle(p0, 6.0, t.panel, Stroke::new(1.5, t.accent)); + }); +} + /// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face. fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) { let Some(id) = app.tree.redefine else { return }; @@ -735,6 +1130,7 @@ fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) { let mut pick: Option = None; egui::Window::new(format!("Redefine Sketch Plane: {name}")) .id(egui::Id::new("sc_redefine")) + .default_pos(panel_pos(app)) .open(&mut open) .resizable(false) .collapsible(false) diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index ab38c08d..95fdf336 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -34,10 +34,15 @@ pub enum Target { Component { id: u64, }, - /// A browser group (folder) of items. + /// A browser group of items. Group { id: u64, }, + /// A component's Bodies, Sketches or Construction folder. + Folder { + component: u64, + folder: String, + }, } /// One menu entry: a command id (run with `params` when given, else started like a toolbar @@ -54,7 +59,11 @@ pub struct Item { } impl Item { - fn sep() -> Item { + /// A view action (`ui.*`). + pub fn action(id: &str, label: &str, icon: &str) -> Item { + act(id, label, icon) + } + pub fn sep() -> Item { Item { id: "-".into(), label: String::new(), shortcut: String::new(), enabled: false, icon: String::new(), params: None } } fn heading(label: &str) -> Item { @@ -66,7 +75,7 @@ impl Item { fn is_heading(&self) -> bool { self.id.starts_with('#') } - fn with(mut self, params: Value) -> Item { + pub fn with(mut self, params: Value) -> Item { self.params = Some(params); self } @@ -186,7 +195,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => vec![name.clone()], Target::Component { id } => component_bodies(app, *id), Target::Group { id } => crate::browser::group_bodies(app, *id), - Target::Sketch { .. } => Vec::new(), + Target::Sketch { .. } | Target::Folder { .. } => Vec::new(), } } @@ -236,13 +245,20 @@ pub fn radial_items(app: &SolveApp) -> Vec { /// The list of a menu (under the ring for the viewport). pub fn items(app: &SolveApp, target: &Target) -> Vec { - match target { + let mut v = match target { Target::Viewport => viewport_items(app), Target::Body { name } => body_items(app, std::slice::from_ref(name), true), Target::Sketch { id } => sketch_items(app, *id), Target::Component { id } => component_items(app, *id), Target::Group { id } => crate::browser::group_items(app, *id), + Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder), + }; + // Several items of the folder selected: they can be grouped. + if let Some(p) = crate::browser::selection_group(app, target) { + v.push(Item::sep()); + v.push(act("ui.groupSelected", "Group Selected", "folder").with(p)); } + v } fn viewport_items(app: &SolveApp) -> Vec { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 204d17dd..613c56aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -192,6 +192,8 @@ impl SolveApp { "pick_bodies": self.ui.pick_bodies, "auto_project": self.session.auto_project, "visual_style": self.ui.visual_style, + "browser_collapsed": self.tree.collapsed, + "browser_expanded": self.tree.expanded, }) .to_string() } @@ -218,6 +220,15 @@ impl SolveApp { if let Some(s) = v.get("visual_style").and_then(Value::as_u64) { self.ui.visual_style = u8::try_from(s.min(2)).unwrap_or(0); } + // Browser folders the user opened or closed. + let keys = |k: &str| -> std::collections::BTreeSet { + v.get(k) + .and_then(Value::as_array) + .map(|a| a.iter().take(10_000).filter_map(|x| x.as_str().map(str::to_string)).collect()) + .unwrap_or_default() + }; + self.tree.collapsed = keys("browser_collapsed"); + self.tree.expanded = keys("browser_expanded"); } pub fn with_control(mut self, rx: Receiver) -> Self { diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 1238e2bf..beb9a494 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -162,3 +162,39 @@ fn pick_runs_an_item_of_the_open_menu() { assert!(app.viewport.context_menu.is_none(), "a pick closes the menu"); assert!(!app.ui.show_origin); } + +#[test] +fn folder_and_group_menus_make_rename_and_drop_groups() { + let mut app = sample_app(); + app.run("PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [200, 0, 0]})).unwrap(); + let names: Vec = app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect(); + let items: Vec<_> = names.iter().map(|n| json!({"type": "body", "name": n})).collect(); + app.run("select.set", json!({ "items": items })).unwrap(); + let folder = Target::Folder { component: 0, folder: "bodies".into() }; + act(&mut app, &folder, "Group Selected"); + let g = app.session.doc.browser_groups[0].clone(); + assert_eq!(g.items.len(), 2); + // A body's menu offers grouping too while several are selected. + assert!(context_menu::items(&app, &Target::Body { name: names[0].clone() }).iter().any(|i| i.label == "Group Selected")); + let t = Target::Group { id: g.id }; + act(&mut app, &t, "Rename"); + assert!(context_menu::finish_rename(&mut app, Some("Parts"), true)); + assert_eq!(app.session.doc.browser_groups[0].name, "Parts"); + act(&mut app, &t, "Hide"); + assert_eq!(app.ui.hidden_bodies.len(), 2); + act(&mut app, &t, "Ungroup"); + assert!(app.session.doc.browser_groups.is_empty()); + act(&mut app, &folder, "New Group"); + assert_eq!(app.session.doc.browser_groups[0].items.len(), 0); +} + +#[test] +fn browser_folds_are_kept_in_the_preferences() { + let mut app = sample_app(); + app.tree.collapsed.insert("c0/sketches".into()); + app.tree.expanded.insert("origin".into()); + let prefs = app.prefs(); + let mut other = sample_app(); + other.load_prefs(&prefs); + assert!(other.tree.collapsed.contains("c0/sketches") && other.tree.expanded.contains("origin")); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 42905c7b..eedaa19e 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -187,7 +187,8 @@ fn build_scene(app: &SolveApp) -> GpuScene { let tk = Tokens::get(); let mut sc = GpuScene::default(); let s = &app.session; - let st = s.model.state(); + // Bodies where their occurrences place them. + let st = s.world_state(); if app.ui.show_grid { let (minor, major) = grid_step(app.cam.half_height()); let ext = (app.cam.half_height() * 3.0 / major).ceil() * major; @@ -301,7 +302,7 @@ pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> { /// the surfaces along the ray (profiles, faces, planes) nearest first. pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { let s = &app.session; - let st = s.model.state(); + let st = s.world_state(); let mut hits = Vec::new(); let (o, d) = proj.ray(pos); // Sketch points of the active sketch. @@ -604,7 +605,7 @@ fn highlight_key(app: &SolveApp) -> u64 { fn build_highlight(app: &SolveApp) -> GpuScene { let t = Tokens::get(); let mut sc = GpuScene::default(); - let st = app.session.model.state(); + let st = app.session.world_state(); let sel = app.highlighted(); let hover = app.viewport.hover.as_ref(); let size = origin_size(app.cam.half_height()); @@ -1106,7 +1107,7 @@ fn select(app: &mut SolveApp, sel: Option, add: bool) { /// active input takes (edges, faces or bodies), otherwise bodies. fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { use crate::selection::{BODIES, EDGES, FACES, PROFILES}; - let st = app.session.model.state(); + let st = app.session.world_state(); let mut out: Vec = Vec::new(); let to2 = |pts: &[Vec3]| -> Vec { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() }; if app.dialog.is_none()