diff --git a/crates/engine/src/cmd/groups.rs b/crates/engine/src/cmd/groups.rs new file mode 100644 index 0000000..77dbc19 --- /dev/null +++ b/crates/engine/src/cmd/groups.rs @@ -0,0 +1,609 @@ +//! Groups: GROUP, UNGROUP, GROUPEDIT, the group listing, and the post-command pass that drops +//! members which no longer exist (an erased member leaves its groups; an empty group goes away). +//! +//! A group is a named (or unnamed, `*A1`, `*A2`…) ordered set of objects in the drawing's +//! `groups`, saved as GROUP objects in the ACAD_GROUP dictionary. The JSON forms never prompt; +//! typed, the commands ask as AutoCAD's command-line versions do. + +use std::sync::Arc; + +use cadcraft_doc::{Drawing, Group, Handle}; +use serde_json::{Value, json}; + +use super::machines::{SelOutcome, SelectPhase}; +use super::*; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +/// The most groups one drawing holds, and the most names one call lists. +const MAX_GROUPS: usize = 100_000; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("group", "Group", run_group) + .menu(&["Tools", "Group"]) + .alias(&["g"]) + .params("{name? (default: an unnamed group *A1, *A2…), description?, handles? (default the selection), selectable?: bool (default true)} → {name, members}") + .interactive(|_| Ok(Box::new(GroupM::default()))), + CommandSpec::new("ungroup", "Ungroup", run_ungroup) + .menu(&["Tools", "Ungroup"]) + .params("{name?: \"G1\" | \"G1,G2\" | [names]} | {handles?} (default the selection: every group holding one of the objects) → {ungrouped: [names]}") + .interactive(|_| Ok(Box::new(UngroupM::default()))), + CommandSpec::new("groupedit", "Group Edit", run_groupedit) + .params("{name, add?: [handles], remove?: [handles], rename?: new name} → {name, members}. Removing every member deletes the group.") + .interactive(|_| Ok(Box::new(GroupEditM::default()))), + CommandSpec::new("groups.list", "List Groups", run_list) + .params("{} → {groups: [{name, description, selectable, unnamed, members: [handles]}]}") + .noundo(), + ] +} + +// ---------------- model helpers ---------------- + +/// A name a new or renamed group may take: not empty, not unnamed-style (`*…`), no characters +/// that are special in names or wildcards. +pub(crate) fn valid_name(n: &str) -> bool { + !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c)) +} + +/// Index of the group called `name` (case-insensitive). +pub(crate) fn find(d: &Drawing, name: &str) -> Option { + let n = name.trim(); + d.groups.iter().position(|g| g.name.eq_ignore_ascii_case(n)) +} + +/// The groups holding `h`, most recently created first. +pub(crate) fn groups_of(d: &Drawing, h: Handle) -> Vec { + d.groups.iter().enumerate().rev().filter(|(_, g)| g.members.contains(&h)).map(|(i, _)| i).collect() +} + +/// The first free unnamed group name: `*A1`, `*A2`… +fn next_unnamed(d: &Drawing) -> String { + let used: std::collections::HashSet = d.groups.iter().map(|g| g.name.to_ascii_uppercase()).collect(); + (1..=d.groups.len() + 1).map(|n| format!("*A{n}")).find(|n| !used.contains(n)).unwrap_or_else(|| format!("*A{}", d.groups.len() + 1)) +} + +/// `hs` without repeats and without handles of objects that don't exist. +fn existing(d: &Drawing, hs: &[Handle]) -> Vec { + let mut seen = std::collections::HashSet::new(); + hs.iter().copied().filter(|h| d.entity(*h).is_some() && seen.insert(*h)).collect() +} + +/// Make a group of `hs`. `name` None (or empty) makes an unnamed group. Returns its name. +pub(crate) fn create(s: &mut Session, name: Option<&str>, description: &str, hs: &[Handle], selectable: bool) -> Result { + let d = s.doc()?; + let members = existing(d, hs); + if members.is_empty() { + return Err(bad("group", "no objects to group")); + } + if d.groups.len() >= MAX_GROUPS { + return Err(bad("group", "the drawing has too many groups")); + } + let name = match name.map(str::trim).filter(|n| !n.is_empty()) { + Some(n) if !valid_name(n) => return Err(bad("group", format!("\"{n}\" is not a valid group name"))), + Some(n) if find(d, n).is_some() => return Err(bad("group", format!("a group named \"{n}\" already exists"))), + Some(n) => n.to_string(), + None => next_unnamed(d), + }; + s.doc_mut()?.groups.push(Group { name: name.clone(), description: description.to_string(), selectable, members }); + Ok(name) +} + +/// Remove the groups at `idx`; their objects stay. Returns the names removed. +pub(crate) fn ungroup(s: &mut Session, idx: &[usize]) -> Result> { + let names: Vec = idx.iter().filter_map(|i| s.doc().ok()?.groups.get(*i).map(|g| g.name.clone())).collect(); + if names.is_empty() { + return Ok(names); + } + let d = s.doc_mut()?; + let mut i = 0; + d.groups.retain(|_| { + let keep = !idx.contains(&i); + i += 1; + keep + }); + Ok(names) +} + +/// Add `hs` (existing objects not already members) to the group at `i`. Returns how many joined. +pub(crate) fn add_members(s: &mut Session, i: usize, hs: &[Handle]) -> Result { + let add: Vec = { + let d = s.doc()?; + let g = d.groups.get(i).ok_or_else(|| bad("groupedit", "no such group"))?; + existing(d, hs).into_iter().filter(|h| !g.members.contains(h)).collect() + }; + if !add.is_empty() + && let Some(g) = s.doc_mut()?.groups.get_mut(i) + { + g.members.extend(&add); + } + Ok(add.len()) +} + +/// Remove `hs` from the group at `i`; a group left with no members is deleted. Returns how many +/// left and whether the group was deleted. +pub(crate) fn remove_members(s: &mut Session, i: usize, hs: &[Handle]) -> Result<(usize, bool)> { + let g = s.doc()?.groups.get(i).ok_or_else(|| bad("groupedit", "no such group"))?; + let n = g.members.iter().filter(|h| hs.contains(h)).count(); + if n == 0 { + return Ok((0, false)); + } + let d = s.doc_mut()?; + let empty = match d.groups.get_mut(i) { + Some(g) => { + g.members.retain(|h| !hs.contains(h)); + g.members.is_empty() + } + None => false, + }; + if empty { + d.groups.remove(i); + } + Ok((n, empty)) +} + +/// Rename the group at `i` (an unnamed group becomes a named one). +pub(crate) fn rename(s: &mut Session, i: usize, new: &str) -> Result<()> { + let new = new.trim(); + if !valid_name(new) { + return Err(bad("groupedit", format!("\"{new}\" is not a valid group name"))); + } + let d = s.doc()?; + if find(d, new).is_some_and(|j| j != i) { + return Err(bad("groupedit", format!("a group named \"{new}\" already exists"))); + } + if let Some(g) = s.doc_mut()?.groups.get_mut(i) { + g.name = new.to_string(); + } + Ok(()) +} + +/// One line per group for `?`: name, description, members. +fn list_lines(d: &Drawing) -> Vec { + if d.groups.is_empty() { + return vec!["No groups defined.".into()]; + } + d.groups + .iter() + .take(MAX_GROUPS) + .map(|g| { + let n = g.members.iter().filter(|h| d.entity(**h).is_some()).count(); + let sel = if g.selectable { "selectable" } else { "not selectable" }; + if g.description.is_empty() { + format!("{}: {n} object(s), {sel}", g.name) + } else { + format!("{}: {}, {n} object(s), {sel}", g.name, g.description) + } + }) + .collect() +} + +fn echo_list(s: &mut Session) -> Result<()> { + for l in list_lines(s.doc()?) { + s.echo(l); + } + Ok(()) +} + +/// The post-command pass: members that no longer exist (erased, moved into a block) leave their +/// groups, and groups left empty are deleted. Cheap when the drawing has no groups or the +/// command changed nothing. +pub(crate) fn after_command(s: &mut Session, before: Option<&Arc>) { + let Ok(st) = s.state_mut() else { return }; + if st.doc.groups.is_empty() || before.is_some_and(|b| Arc::ptr_eq(b, &st.doc)) { + return; + } + let d: &Drawing = &st.doc; + let stale = d.groups.iter().any(|g| g.members.is_empty() || g.members.iter().any(|h| d.entity(*h).is_none())); + if !stale { + return; + } + let groups: Vec = d + .groups + .iter() + .map(|g| Group { members: g.members.iter().copied().filter(|h| d.entity(*h).is_some()).collect(), ..g.clone() }) + .filter(|g| !g.members.is_empty()) + .collect(); + Arc::make_mut(&mut st.doc).groups = groups; +} + +// ---------------- JSON forms ---------------- + +/// Handles under `key` (hex strings or numbers); absent is empty. +fn handles_at(cmd: &str, p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::Array(a)) => a + .iter() + .take(MAX_GROUPS * 10) + .map(|v| { + v.as_str() + .and_then(Handle::parse_hex) + .or_else(|| v.as_u64().map(Handle)) + .ok_or_else(|| bad(cmd, format!("`{key}`: {v} is not an object handle"))) + }) + .collect(), + Some(v) => Err(bad(cmd, format!("`{key}` must be an array of object handles, got {v}"))), + } +} + +fn run_group(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let name = create(s, str_param(p, "name"), str_param(p, "description").unwrap_or_default(), &hs, bool_or(p, "selectable", true))?; + let n = s.doc()?.groups.iter().find(|g| g.name == name).map_or(0, |g| g.members.len()); + Ok(json!({ "name": name, "members": n })) +} + +fn run_ungroup(s: &mut Session, p: &Value) -> Result { + let d = s.doc()?; + let names: Option> = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) => Some(n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()), + Some(Value::Array(a)) => Some( + a.iter() + .take(MAX_GROUPS) + .map(|v| v.as_str().map(|x| x.trim().to_string()).ok_or_else(|| bad("ungroup", "`name` must hold group names"))) + .collect::>()?, + ), + Some(v) => return Err(bad("ungroup", format!("`name` must be a group name or an array of names, got {v}"))), + }; + let mut idx: Vec = match names { + Some(names) => names.iter().map(|n| find(d, n).ok_or_else(|| bad("ungroup", format!("no group named \"{n}\"")))).collect::>()?, + None => targets(s, p)?.iter().flat_map(|h| groups_of(d, *h)).collect(), + }; + idx.sort_unstable(); + idx.dedup(); + if idx.is_empty() { + return Err(bad("ungroup", "the objects are not in a group")); + } + let names = ungroup(s, &idx)?; + Ok(json!({ "ungrouped": names })) +} + +fn run_groupedit(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("groupedit", "`name` is required"))?; + let i = find(s.doc()?, name).ok_or_else(|| bad("groupedit", format!("no group named \"{name}\"")))?; + let add = handles_at("groupedit", p, "add")?; + let remove = handles_at("groupedit", p, "remove")?; + if let Some(h) = add.iter().find(|h| s.doc().is_ok_and(|d| d.entity(**h).is_none())) { + return Err(bad("groupedit", format!("`add`: no object with handle {}", h.hex()))); + } + let new_name = str_param(p, "rename"); + // Check the new name before changing anything. + if let Some(n) = new_name { + let n = n.trim(); + if !valid_name(n) || find(s.doc()?, n).is_some_and(|j| j != i) { + return Err(bad("groupedit", format!("\"{n}\" is not a valid new group name, or a group of that name exists"))); + } + } + add_members(s, i, &add)?; + let (_, deleted) = remove_members(s, i, &remove)?; + if deleted { + return Ok(json!({ "name": name, "members": 0, "deleted": true })); + } + if let Some(n) = new_name { + rename(s, i, n)?; + } + let g = s.doc()?.groups.get(i).ok_or_else(|| bad("groupedit", "no such group"))?; + Ok(json!({ "name": g.name, "members": g.members.len() })) +} + +fn run_list(s: &mut Session, _p: &Value) -> Result { + let d = s.doc()?; + let groups: Vec = d + .groups + .iter() + .map(|g| { + let members: Vec = g.members.iter().filter(|h| d.entity(**h).is_some()).map(|h| h.hex()).collect(); + json!({ "name": g.name, "description": g.description, "selectable": g.selectable, "unnamed": g.name.starts_with('*'), "members": members }) + }) + .collect(); + Ok(json!({ "groups": groups })) +} + +// ---------------- prompts ---------------- + +/// The keyword typed or picked, in lower case. +fn kw(i: &Input) -> Option { + match i { + Input::Keyword(k) => Some(k.to_ascii_lowercase()), + _ => None, + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum GroupStage { + #[default] + Select, + Name, + Description, +} + +/// GROUP: "Select objects or [Name/Description]:", ending with Enter. With a pickfirst selection +/// the unnamed group is made at once. +#[derive(Default)] +struct GroupM { + stage: GroupStage, + name: Option, + description: String, + sel: SelectPhase, +} + +impl GroupM { + fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result { + match create(s, self.name.as_deref(), &self.description, hs, true) { + Ok(n) if n.starts_with('*') => s.echo(format!("Unnamed group {n} created with {} object(s).", hs.len())), + Ok(n) => s.echo(format!("Group \"{n}\" created with {} object(s).", hs.len())), + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Done) + } +} + +impl Interactive for GroupM { + fn name(&self) -> &'static str { + "GROUP" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.finish(s, &hs); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + GroupStage::Select if self.sel.removing => self.sel.prompt(), + GroupStage::Select => Prompt::new("Select objects", Accept::SELECT).kw(&["Name", "Description"]), + GroupStage::Name => Prompt::new("Enter a group name", Accept::TEXT).kw(&["?"]), + GroupStage::Description => Prompt::new("Enter group description", Accept::TEXT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + GroupStage::Select => match kw(&i).as_deref() { + Some("name") => { + self.stage = GroupStage::Name; + Ok(Step::Continue) + } + Some("description") => { + self.stage = GroupStage::Description; + Ok(Step::Continue) + } + _ => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + let step = self.finish(s, &hs); + s.set_selection(Vec::new()); + step + } + }, + }, + GroupStage::Name => { + match &i { + Input::Keyword(k) if k == "?" => echo_list(s)?, + Input::Text(t) => { + let n = t.trim(); + if !valid_name(n) { + s.echo(format!("\"{n}\" is not a valid group name.")); + } else if find(s.doc()?, n).is_some() { + s.echo(format!("A group named \"{n}\" already exists.")); + } else { + self.name = Some(n.to_string()); + self.stage = GroupStage::Select; + } + } + // Enter keeps the group unnamed. + Input::Enter => self.stage = GroupStage::Select, + _ => {} + } + Ok(Step::Continue) + } + GroupStage::Description => { + match &i { + Input::Text(t) => self.description = t.trim().to_string(), + Input::Enter => self.description.clear(), + _ => return Ok(Step::Continue), + } + self.stage = GroupStage::Select; + Ok(Step::Continue) + } + } + } +} + +/// The group a "Select group or [Name]" pick means: the most recent group of the first picked +/// object that is in one. +fn picked_group(d: &Drawing, hs: &[Handle]) -> Option { + hs.iter().find_map(|h| groups_of(d, *h).first().copied()) +} + +/// UNGROUP: "Select group or [Name]:". With a pickfirst selection, every group holding a +/// selected object is ungrouped at once. +#[derive(Default)] +struct UngroupM { + by_name: bool, +} + +impl UngroupM { + fn done(s: &mut Session, idx: &[usize]) -> Result { + for n in ungroup(s, idx)? { + s.echo(format!("Group {n} ungrouped.")); + } + Ok(Step::Done) + } +} + +impl Interactive for UngroupM { + fn name(&self) -> &'static str { + "UNGROUP" + } + fn begin(&mut self, s: &mut Session) -> Result { + let pre = s.selection(); + if pre.is_empty() || !s.settings.pickfirst { + return Ok(Step::Continue); + } + let d = s.doc()?; + let mut idx: Vec = pre.iter().flat_map(|h| groups_of(d, *h)).collect(); + idx.sort_unstable(); + idx.dedup(); + if idx.is_empty() { + return Ok(Step::Continue); + } + s.set_selection(Vec::new()); + Self::done(s, &idx) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.by_name { + Prompt::new("Enter a group name", Accept::TEXT).kw(&["?"]) + } else { + Prompt::new("Select group", Accept::SELECT).kw(&["Name"]) + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.by_name { + match &i { + Input::Keyword(k) if k == "?" => echo_list(s)?, + Input::Text(t) => match find(s.doc()?, t) { + Some(g) => return Self::done(s, &[g]), + None => s.echo(format!("No group named \"{}\".", t.trim())), + }, + Input::Enter => return Ok(Step::Done), + _ => {} + } + return Ok(Step::Continue); + } + match (&i, kw(&i).as_deref()) { + (_, Some("name")) => self.by_name = true, + (Input::Pick(hs), _) => match picked_group(s.doc()?, hs) { + Some(g) => { + s.set_selection(Vec::new()); + return Self::done(s, &[g]); + } + None => { + s.set_selection(Vec::new()); + s.echo(if hs.is_empty() { "0 found" } else { "The object is not a member of any group." }); + } + }, + (Input::Enter, _) => return Ok(Step::Done), + _ => {} + } + Ok(Step::Continue) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum EditStage { + #[default] + Pick, + Name, + Option, + Add, + Remove, + Rename, +} + +/// GROUPEDIT: pick a group (or name it), then Add objects, Remove objects or REName. +#[derive(Default)] +struct GroupEditM { + stage: EditStage, + /// The group being edited, by name (indices move when groups are deleted). + group: String, + sel: SelectPhase, +} + +impl GroupEditM { + fn index(&self, s: &Session) -> Result { + find(s.doc()?, &self.group).ok_or_else(|| bad("groupedit", format!("no group named \"{}\"", self.group))) + } + fn chosen(&mut self, s: &Session, i: usize) -> Result<()> { + self.group = s.doc()?.groups.get(i).map(|g| g.name.clone()).unwrap_or_default(); + self.stage = EditStage::Option; + Ok(()) + } +} + +impl Interactive for GroupEditM { + fn name(&self) -> &'static str { + "GROUPEDIT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + EditStage::Pick => Prompt::new("Select group", Accept::SELECT).kw(&["Name"]), + EditStage::Name => Prompt::new("Enter a group name", Accept::TEXT).kw(&["?"]), + EditStage::Option => Prompt::new("Enter an option", super::curves::KW).kw(&["Add objects", "Remove objects", "REName"]), + EditStage::Add if self.sel.removing => self.sel.prompt(), + EditStage::Add => Prompt::new("Select objects to add to the group", Accept::SELECT), + EditStage::Remove => Prompt::new("Select objects to remove from the group", Accept::SELECT), + EditStage::Rename => Prompt::new("Enter a new name for the group", Accept::TEXT).kw(&["?"]).default(self.group.clone()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + EditStage::Pick => match (&i, kw(&i).as_deref()) { + (_, Some("name")) => self.stage = EditStage::Name, + (Input::Pick(hs), _) => { + let g = picked_group(s.doc()?, hs); + s.set_selection(Vec::new()); + match g { + Some(g) => self.chosen(s, g)?, + None => s.echo(if hs.is_empty() { "0 found" } else { "The object is not a member of any group." }), + } + } + (Input::Enter, _) => return Ok(Step::Done), + _ => {} + }, + EditStage::Name => match &i { + Input::Keyword(k) if k == "?" => echo_list(s)?, + Input::Text(t) => match find(s.doc()?, t) { + Some(g) => self.chosen(s, g)?, + None => s.echo(format!("No group named \"{}\".", t.trim())), + }, + Input::Enter => return Ok(Step::Done), + _ => {} + }, + EditStage::Option => match kw(&i).as_deref() { + Some("add objects") => self.stage = EditStage::Add, + Some("remove objects") => self.stage = EditStage::Remove, + Some("rename") => self.stage = EditStage::Rename, + _ if i == Input::Enter => return Ok(Step::Done), + _ => s.echo("Invalid option."), + }, + EditStage::Add | EditStage::Remove => match self.sel.feed(s, &i)? { + SelOutcome::More => {} + SelOutcome::Empty => return Ok(Step::Done), + SelOutcome::Done(hs) => { + s.set_selection(Vec::new()); + let g = self.index(s)?; + if self.stage == EditStage::Add { + let n = add_members(s, g, &hs)?; + s.echo(format!("{n} object(s) added to group {}.", self.group)); + } else { + match remove_members(s, g, &hs)? { + (n, false) => s.echo(format!("{n} object(s) removed from group {}.", self.group)), + (_, true) => s.echo(format!("Group {} has no objects left and was deleted.", self.group)), + } + } + return Ok(Step::Done); + } + }, + EditStage::Rename => match &i { + Input::Keyword(k) if k == "?" => echo_list(s)?, + Input::Text(t) => { + let g = self.index(s)?; + match rename(s, g, t) { + Ok(()) => { + s.echo(format!("Group {} renamed to {}.", self.group, t.trim())); + return Ok(Step::Done); + } + Err(e) => s.echo(e.to_string()), + } + } + Input::Enter => return Ok(Step::Done), + _ => {} + }, + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0526fdc..a58dd70 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ mod draw2; mod edit; pub mod file; mod gripcmds; +pub(crate) mod groups; mod hatch; mod inquiry; mod layer; @@ -168,6 +169,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(modify::specs()); v.extend(array::specs()); v.extend(modify2::specs()); + v.extend(groups::specs()); v.extend(gripcmds::specs()); v.extend(layer::specs()); v.extend(layout::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 55be3a5..ed2120c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -648,6 +648,7 @@ impl Session { return Err(EngineError::Internal(id.into(), msg)); } }; + cmd::groups::after_command(self, before.as_ref().map(|b| &b.0)); cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok()); assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2)); let result = match (result, &before) { @@ -876,6 +877,7 @@ impl Session { self.pending_window = None; let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command"); let _ = cancelled; + cmd::groups::after_command(self, Some(&run.before)); cmd::constraints::after_command(self, Some(&run.before), true); assoc::after_command(self, Some(&run.before), None); if let Ok(st) = self.state_mut() diff --git a/crates/engine/tests/groups.rs b/crates/engine/tests/groups.rs new file mode 100644 index 0000000..76135d9 --- /dev/null +++ b/crates/engine/tests/groups.rs @@ -0,0 +1,157 @@ +//! Groups (#417): GROUP, UNGROUP and GROUPEDIT, typed and as JSON; erased members leave their +//! groups and empty groups go away. (The DXF round trip of groups is tested in +//! `cadcraft-io`'s `tables_objects_roundtrip`.) + +use cadcraft_engine::doc::Handle; +use cadcraft_engine::{Input, Session}; +use serde_json::json; + +/// A session with three lines; their handles. +fn three_lines() -> (Session, Vec) { + let mut s = Session::new(); + for y in [0, 5, 10] { + s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap(); + } + let hs = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + (s, hs) +} + +fn hex(hs: &[Handle]) -> Vec { + hs.iter().map(|h| h.hex()).collect() +} + +fn groups(s: &Session) -> Vec<(String, String, Vec)> { + s.doc().unwrap().groups.iter().map(|g| (g.name.clone(), g.description.clone(), g.members.clone())).collect() +} + +#[test] +fn group_json_named_and_unnamed() { + let (mut s, hs) = three_lines(); + let r = s.execute("group", &json!({ "name": "Doors", "description": "front", "handles": hex(&hs[..2]) })).unwrap(); + assert_eq!(r, json!({ "name": "Doors", "members": 2 })); + // Unnamed groups are *A1, *A2…; the current selection is the default. + s.set_selection(vec![hs[2]]); + let r = s.execute("group", &json!({})).unwrap(); + assert_eq!(r["name"], "*A1"); + assert_eq!(groups(&s), vec![("Doors".into(), "front".into(), hs[..2].to_vec()), ("*A1".into(), String::new(), vec![hs[2]])]); + // A taken name (any case), an invalid name or nothing to group is refused. + assert!(s.execute("group", &json!({ "name": "DOORS", "handles": hex(&hs) })).is_err()); + assert!(s.execute("group", &json!({ "name": "a*b", "handles": hex(&hs) })).is_err()); + s.set_selection(Vec::new()); + assert!(s.execute("group", &json!({ "name": "Empty" })).is_err()); + assert!(s.execute("group", &json!({ "name": "Gone", "handles": ["FFFFF"] })).is_err()); + assert!(s.execute("group", &json!({ "handles": "1A" })).is_err()); + assert_eq!(s.doc().unwrap().groups.len(), 2); + let list = s.execute("groups.list", &json!({})).unwrap(); + assert_eq!(list["groups"][1]["unnamed"], true); + assert_eq!(list["groups"][0]["members"], json!(hex(&hs[..2]))); + assert!(s.running.is_none(), "JSON calls never prompt"); +} + +#[test] +fn group_typed_name_description_and_undo() { + let (mut s, hs) = three_lines(); + let undo = s.state().unwrap().undo.len(); + s.cmdline("group").unwrap(); + assert!(s.prompt_text().contains("Select objects or [Name/Description]"), "{}", s.prompt_text()); + s.cmdline("n").unwrap(); + assert!(s.prompt_text().contains("Enter a group name"), "{}", s.prompt_text()); + s.cmdline("Chairs").unwrap(); + s.cmdline("d").unwrap(); + s.cmdline("four legs each").unwrap(); + s.input(Input::Pick(vec![hs[0], hs[1]])).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(groups(&s), vec![("Chairs".into(), "four legs each".into(), hs[..2].to_vec())]); + assert!(s.selection().is_empty(), "picked objects don't stay selected"); + assert_eq!(s.state().unwrap().undo.len(), undo + 1, "one undo step"); + // A name in use is refused and asked again. + s.cmdline("group n chairs").unwrap(); + assert!(s.log.iter().any(|l| l.contains("already exists")), "{:?}", s.log); + assert!(s.prompt_text().contains("Enter a group name"), "{}", s.prompt_text()); + s.cancel(); + s.execute("u", &json!({})).unwrap(); + assert!(s.doc().unwrap().groups.is_empty(), "undo removes the group"); + // With a pickfirst selection GROUP makes an unnamed group at once. + s.set_selection(vec![hs[2]]); + s.cmdline("g").unwrap(); + assert!(s.running.is_none()); + assert_eq!(groups(&s), vec![("*A1".into(), String::new(), vec![hs[2]])]); +} + +#[test] +fn ungroup_by_pick_name_and_json() { + let (mut s, hs) = three_lines(); + s.execute("group", &json!({ "name": "A", "handles": hex(&hs[..2]) })).unwrap(); + s.execute("group", &json!({ "name": "B", "handles": hex(&hs[2..]) })).unwrap(); + s.execute("group", &json!({ "name": "C", "handles": hex(&hs[2..]) })).unwrap(); + // Typed: pick a member; a non-member is reported and asked again. + s.execute("line", &json!({ "points": [[0, 20], [10, 20]] })).unwrap(); + let loose = s.doc().unwrap().model.last().unwrap().handle; + s.cmdline("ungroup").unwrap(); + assert!(s.prompt_text().contains("Select group or [Name]"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![loose])).unwrap(); + assert!(s.running.is_some()); + s.input(Input::Pick(vec![hs[1]])).unwrap(); + assert!(s.running.is_none()); + assert_eq!(groups(&s).iter().map(|g| g.0.as_str()).collect::>(), ["B", "C"]); + assert_eq!(s.doc().unwrap().model.len(), 4, "the objects stay"); + // By name. + s.cmdline("ungroup n c").unwrap(); + assert!(s.running.is_none()); + assert_eq!(groups(&s).iter().map(|g| g.0.as_str()).collect::>(), ["B"]); + // JSON: unknown names and objects in no group are errors. + assert!(s.execute("ungroup", &json!({ "name": "nope" })).is_err()); + assert!(s.execute("ungroup", &json!({ "handles": [loose.hex()] })).is_err()); + let r = s.execute("ungroup", &json!({ "handles": [hs[2].hex()] })).unwrap(); + assert_eq!(r, json!({ "ungrouped": ["B"] })); + assert!(s.doc().unwrap().groups.is_empty()); +} + +#[test] +fn groupedit_add_remove_rename() { + let (mut s, hs) = three_lines(); + s.execute("group", &json!({ "handles": hex(&hs[..1]) })).unwrap(); + // Typed: pick the group, add an object, then rename the unnamed group. + s.cmdline("groupedit").unwrap(); + s.input(Input::Pick(vec![hs[0]])).unwrap(); + assert!(s.prompt_text().contains("[Add objects/Remove objects/REName]"), "{}", s.prompt_text()); + s.cmdline("a").unwrap(); + s.input(Input::Pick(vec![hs[1]])).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(groups(&s), vec![("*A1".into(), String::new(), hs[..2].to_vec())]); + s.cmdline("groupedit n *a1 ren Pair").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(groups(&s)[0].0, "Pair"); + // Remove one; removing the last deletes the group. + s.cmdline("groupedit n pair r").unwrap(); + s.input(Input::Pick(vec![hs[0]])).unwrap(); + s.cmdline("").unwrap(); + assert_eq!(groups(&s), vec![("Pair".into(), String::new(), vec![hs[1]])]); + // JSON. + let r = s.execute("groupedit", &json!({ "name": "pair", "add": hex(&hs), "rename": "Trio" })).unwrap(); + assert_eq!(r, json!({ "name": "Trio", "members": 3 })); + assert!(s.execute("groupedit", &json!({ "name": "Trio", "add": ["FFFFF"] })).is_err()); + assert!(s.execute("groupedit", &json!({ "name": "Trio", "rename": "bad?" })).is_err()); + assert!(s.execute("groupedit", &json!({ "name": "nope" })).is_err()); + let r = s.execute("groupedit", &json!({ "name": "Trio", "remove": hex(&hs) })).unwrap(); + assert_eq!(r["deleted"], true); + assert!(s.doc().unwrap().groups.is_empty()); +} + +#[test] +fn erased_members_leave_groups() { + let (mut s, hs) = three_lines(); + s.execute("group", &json!({ "name": "A", "handles": hex(&hs[..2]) })).unwrap(); + s.execute("group", &json!({ "name": "B", "handles": hex(&hs[2..]) })).unwrap(); + s.execute("erase", &json!({ "handles": [hs[0].hex(), hs[2].hex()] })).unwrap(); + assert_eq!(groups(&s), vec![("A".into(), String::new(), vec![hs[1]])], "B lost its only member"); + s.execute("u", &json!({})).unwrap(); + assert_eq!(groups(&s).len(), 2, "undo restores both groups"); + // Typed ERASE too. + s.cmdline("erase").unwrap(); + s.input(Input::Pick(vec![hs[0], hs[1]])).unwrap(); + s.cmdline("").unwrap(); + assert_eq!(groups(&s).iter().map(|g| g.0.as_str()).collect::>(), ["B"]); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0089151..fd885ad 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -309,6 +309,8 @@ Grip Rotate Поворот ручками Grip Scale Масштабування ручками Grip Stretch Розтягування ручками + Group Згрупувати + Group Edit Редагувати групу Hatch Штрихування Hatch Edit Редагувати штрихування Hatch... Штрихування... @@ -398,6 +400,7 @@ List Список List Blocks Список блоків List Dimension Styles Список розмірних стилів + List Groups Список груп List Layer States Список станів шарів List Layouts Список аркушів List System Variables Список системних змінних @@ -900,6 +903,7 @@ UCS Icon Піктограма СКК Undo Скасувати дію Undo (⌘Z) Скасувати дію (⌘Z) + Ungroup Розгрупувати Union Об'єднання Unisolate Layer Скасувати ізоляцію шарів Units... Одиниці...