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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user