//! 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}; use solvecraft_doc::FeatureKind; use super::CommandSpec; use crate::params::{bad, str_}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"), 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 { return false; } match v { Value::String(s) if s == old => { *s = new.to_string(); true } Value::Array(a) => a.iter_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc), Value::Object(o) => o.values_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc), _ => false, } } fn rename_body(s: &mut Session, p: &Value) -> Result { let cmd = "body.rename"; let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string(); 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 name == old { return Ok(json!({"body": old, "name": name})); } let st = s.model.state(); let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?; if st.body(name).is_some() { return Err(bad(cmd, format!("another body is called `{name}`"))); } // The body is the k-th new body of its feature; the names before it are kept as they are. let fid = b.feature; let siblings: Vec = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect(); let k = siblings.iter().position(|n| *n == old).unwrap_or(0); let d = s.doc_mut(); let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?; while f.body_names.len() <= k { let i = f.body_names.len(); f.body_names.push(siblings.get(i).cloned().unwrap_or_default()); } if let Some(slot) = f.body_names.get_mut(k) { *slot = name.to_string(); } // Later features refer to bodies by name. let mut failed = None; for f in d.features.iter_mut().filter(|f| f.id != fid) { let Ok(mut v) = serde_json::to_value(&f.kind) else { continue }; if replace_str(&mut v, &old, name, 0) { match serde_json::from_value(v) { Ok(k) => f.kind = k, Err(e) => failed = Some(e.to_string()), } } } if let Some(e) = failed { return Err(bad(cmd, e)); } if let Some(m) = d.materials.remove(&old) { d.materials.insert(name.to_string(), m); } 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})) } fn redefine_sketch(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.redefine"; let key = match p.get("sketch") { Some(Value::Number(n)) => n.to_string(), Some(Value::String(x)) => x.clone(), _ => return Err(bad(cmd, "`sketch` must be an id or name")), }; let id = match s.doc.find_feature(&key) { Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => f.id, _ => return Err(bad(cmd, format!("no sketch `{key}`"))), }; if p.get("plane").is_none() { return Err(bad(cmd, "`plane` is required")); } let plane = super::sketch::plane_ref(s, p, cmd)?; let (vals, _) = s.doc.param_values(); s.doc.resolve_plane(&vals, &plane, 0)?; if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) { *slot = plane; } Ok(json!({ "sketch": id })) } #[cfg(test)] mod tests { use serde_json::{Value, json}; use crate::Session; fn run(s: &mut Session, id: &str, p: Value) -> Value { match s.execute(id, &p) { Ok(v) => v, Err(e) => panic!("{id} {p}: {e}"), } } fn names(s: &Session) -> Vec { s.model.state().bodies.iter().map(|b| b.name.clone()).collect() } #[test] fn renamed_body_keeps_its_users() { 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()]; run(&mut s, "FusionMoveCommand", json!({"bodies": [b.clone()], "translate": [0, 0, 5]})); run(&mut s, "body.rename", json!({"body": b, "name": "Lid"})); assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]); assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body"); // Duplicate names and unknown bodies are refused; undo restores the old name. assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err()); assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err()); assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err()); run(&mut s, "UndoCommand", json!({})); assert_eq!(names(&s)[1], b); } #[test] fn redefined_sketch_moves_its_extrude() { let mut s = Session::default(); let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]})); run(&mut s, "SketchStop", json!({})); run(&mut s, "Extrude", json!({"distance": 5})); let zmax = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap(); assert!((zmax(&mut s) - 5.0).abs() < 1e-6); run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"})); let z = zmax(&mut s); assert!(z > 9.0, "the profile stands up on XZ: {z}"); 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"); } }