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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
7e373ff1f7
commit
9a9caa6511
7 files changed
+839
-174
No files matched your search
@@ -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<String, u64>,
|
||||
/// Browser groups (folders of bodies, sketches or construction planes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub browser_groups: Vec<BrowserGroup>,
|
||||
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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<u64> {
|
||||
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<Vec<String>> {
|
||||
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::<Option<Vec<_>>>()
|
||||
}
|
||||
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<u64> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+560
-164
@@ -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<String> {
|
||||
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<Item> {
|
||||
Vec::new()
|
||||
/// The bodies in a group (none for sketch and plane groups).
|
||||
pub fn group_bodies(app: &SolveApp, id: u64) -> Vec<String> {
|
||||
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<String> {
|
||||
None
|
||||
pub fn group_name(app: &SolveApp, id: u64) -> Option<String> {
|
||||
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<Item> {
|
||||
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<Item> {
|
||||
let sel: Vec<String> = 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<Value> {
|
||||
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<egui::Response>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
drop: Option<DropAction>,
|
||||
}
|
||||
|
||||
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<u64> = 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>) -> (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<Color32>,
|
||||
locked: bool,
|
||||
sel: Option<Sel>,
|
||||
target: Option<Target>,
|
||||
/// Bodies lit when the row is hovered.
|
||||
bodies: Vec<String>,
|
||||
}
|
||||
|
||||
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<String> = 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<String>,
|
||||
}
|
||||
|
||||
/// The items of a component folder, in model order.
|
||||
fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
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<Entry>) -> (Vec<(solvecraft_engine::doc::BrowserGroup, Vec<Entry>)>, Vec<Entry>) {
|
||||
let doc = &app.session.doc;
|
||||
let groups: Vec<solvecraft_engine::doc::BrowserGroup> =
|
||||
doc.browser_groups.iter().filter(|g| g.component == comp && g.folder == folder).cloned().collect();
|
||||
let mut rest: Vec<Option<Entry>> = 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<Entry> = 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::<Vec<_>>());
|
||||
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<String> = 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<u64>,
|
||||
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<String> = 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<String> = 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<String> = 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<String> {
|
||||
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<std::sync::Arc<Drag>> {
|
||||
let t = Tokens::get();
|
||||
let hovering = resp.dnd_hover_payload::<Drag>().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::<Drag>().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<String>, group: Option<u64>, before: Option<String> },
|
||||
/// Out of groups, in this order of the folder's loose items.
|
||||
Order { comp: u64, folder: &'static str, keys: Vec<String>, order: Vec<String> },
|
||||
/// Into another component (bodies and sketches).
|
||||
Component { to: u64, folder: &'static str, keys: Vec<String> },
|
||||
}
|
||||
|
||||
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<solvecraft_engine::geom::Vec3> {
|
||||
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<Value> = 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)
|
||||
|
||||
@@ -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<String> {
|
||||
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<Item> {
|
||||
|
||||
/// The list of a menu (under the ring for the viewport).
|
||||
pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
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<Item> {
|
||||
|
||||
@@ -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<String> {
|
||||
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<ControlRequest>) -> Self {
|
||||
|
||||
@@ -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<String> = 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"));
|
||||
}
|
||||
@@ -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<Hit> {
|
||||
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<Sel>, 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<Sel> = Vec::new();
|
||||
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };
|
||||
if app.dialog.is_none()
|
||||
|
||||
Reference in new issue
Block a user