Browse / github.com/storytold/cadcraft / patch
feat(engine,ui): close F1 (-ATTEDIT, -RENAME), F16 (groups), F19 (draworder) and -LAYER stAte
feat(engine,ui): close F1 (-ATTEDIT, -RENAME), F16 (groups), F19 (draworder) and -LAYER stAte ## Summary This PR addresses several gaps towards the Alpha release milestone: 1. **F1 [alpha blocker] (-ATTEDIT & -RENAME)**: - `-ATTEDIT`: implemented one-at-a-time attribute editing for `Position`, `Height`, `Angle`, and `Style`. - `-RENAME`: enabled renaming of saved viewport configurations (`VPort`), and prevented renaming of `*Active`. 2. **-LAYER stAte**: - Implemented interactive prompt options `[?/Save/Restore/Edit/Name/Delete]` dispatching to the corresponding `layerstate.*` engine commands. 3. **F16 (Groups)**: - Implemented group replication on entity copy (`transfor
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- aljormon84 (imported from pull request #2152, open)
- Base
754b609d09e884bcec545ee84aec7e8e702aa53e- Size
- +530 −39 in 18 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f5f05fdac7c7beab0843c1ed
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/blocks/attedit.rs crates/engine/src/cmd/clipboard.rs crates/engine/src/cmd/draworder.rs crates/engine/src/cmd/groups.rs crates/engine/src/cmd/layer/prompts.rs crates/engine/src/cmd/modify.rs crates/engine/src/cmd/props.rs crates/engine/src/cmd/props/rename.rs crates/engine/src/lib.rs crates/engine/src/sysvars.rs crates/engine/tests/it/attedit_prompts.rs crates/engine/tests/it/attrib_props.rs crates/engine/tests/it/draworder.rs crates/engine/tests/it/groups.rs crates/engine/tests/it/layer_cmdline.rs crates/engine/tests/it/rename_cmdline.rs crates/ui-egui/src/canvas.rs docs/gaps.md
@@ -4,6 +4,7 @@ use cadcraft_doc::color::Color; use cadcraft_doc::{AttribProps, Common, EntityKind, Handle};+use cadcraft_geom::Vec3; use serde_json::{Map, Value, json}; use crate::cmd::curves::KW;@@ -149,6 +150,18 @@ fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut Attri Ok(()) } +/// Modify the attribute `k` of block reference `h`.+fn modify_attrib(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut cadcraft_doc::Attrib)) -> Result<()> {+ s.doc_mut()?.modify_entity(h, |e| {+ if let EntityKind::Insert(ins) = &mut e.kind+ && let Some(a) = ins.attribs.get_mut(k)+ {+ f(a);+ }+ })?;+ Ok(())+}+ /// The layer and colour attribute `k` of block reference `h` is drawn with: its own, else its /// definition's (layer 0 is the reference's layer). fn current_props(s: &Session, (h, k): (Handle, usize)) -> Option<(String, Color)> {@@ -164,6 +177,14 @@ fn current_props(s: &Session, (h, k): (Handle, usize)) -> Option<(String, Color) Some((layer, c.color)) } +/// The current text properties of attribute `k` of block reference `h`.+fn current_text(s: &Session, (h, k): (Handle, usize)) -> Option<cadcraft_doc::Text> {+ let d = s.doc().ok()?;+ let e = d.entity(h)?;+ let EntityKind::Insert(ins) = &e.kind else { return None };+ ins.attribs.get(k).map(|a| a.text.clone())+}+ /// Replace every `find` in `value` with `with`, unless the result would be too long. fn replaced(value: &str, find: &str, with: &str) -> Option<String> { if find.is_empty() || !value.contains(find) {@@ -276,6 +297,10 @@ enum Stage { Select, /// One at a time: what to do with the current attribute. Option,+ Position,+ Height,+ Angle,+ Style, ValueKind, NewValue, Layer,@@ -349,6 +374,7 @@ impl Interactive for DashAtteditM { } fn prompt(&self, s: &Session) -> Prompt { let cur = self.items.get(self.cur).and_then(|it| current_props(s, *it));+ let cur_text = self.items.get(self.cur).and_then(|it| current_text(s, *it)); match self.stage { Stage::OneAtATime => Prompt::new("Edit attributes one at a time?", KW).kw(&["Yes", "No"]).default("Y"), Stage::VisibleOnly => Prompt::new("Edit only attributes visible on screen?", KW).kw(&["Yes", "No"]).default("Y"),@@ -356,7 +382,31 @@ impl Interactive for DashAtteditM { Stage::TagSpec => Prompt::new("Enter attribute tag specification", Accept::TEXT).default("*"), Stage::ValueSpec => Prompt::new("Enter attribute value specification", Accept::TEXT).default("*"), Stage::Select => Prompt::new("Select attributes", Accept::SELECT),- Stage::Option => Prompt::new("Enter an option", KW).kw(&["Value", "Layer", "Color", "Next"]).default("N"),+ Stage::Option => {+ Prompt::new("Enter an option", KW).kw(&["Value", "Position", "Height", "Angle", "Style", "Layer", "Color", "Next"]).default("N")+ }+ Stage::Position => Prompt::new("Enter new text insertion point", Accept::POINT),+ Stage::Height => {+ let p = Prompt::new("Enter new text height", Accept::POINT_OR_NUMBER);+ match cur_text {+ Some(t) => p.default(format!("{:.4}", t.height)),+ None => p,+ }+ }+ Stage::Angle => {+ let p = Prompt::new("Enter new text rotation angle", Accept::POINT_OR_NUMBER);+ match cur_text {+ Some(t) => p.default(format!("{:.4}", t.rotation.to_degrees())),+ None => p,+ }+ }+ Stage::Style => {+ let p = Prompt::new("Enter new text style", Accept::TEXT);+ match cur_text {+ Some(t) => p.default(t.style),+ None => p,+ }+ } Stage::ValueKind => Prompt::new("Enter type of value modification", KW).kw(&["Change", "Replace"]).default("R"), Stage::NewValue => Prompt::new("Enter new attribute value", Accept::TEXT), Stage::Layer => {@@ -430,6 +480,10 @@ impl Interactive for DashAtteditM { } Stage::Option => match &i { Input::Keyword(k) if k == "Value" => self.stage = Stage::ValueKind,+ Input::Keyword(k) if k == "Position" => self.stage = Stage::Position,+ Input::Keyword(k) if k == "Height" => self.stage = Stage::Height,+ Input::Keyword(k) if k == "Angle" => self.stage = Stage::Angle,+ Input::Keyword(k) if k == "Style" => self.stage = Stage::Style, Input::Keyword(k) if k == "Layer" => self.stage = Stage::Layer, Input::Keyword(k) if k == "Color" => self.stage = Stage::Color, Input::Keyword(_) | Input::Enter => {@@ -441,14 +495,110 @@ impl Interactive for DashAtteditM { } Input::Text(t) => { let tl = t.trim().to_ascii_lowercase();- let later = ["Position", "Height", "Angle", "Style"];- return match later.iter().find(|k| !tl.is_empty() && k.to_ascii_lowercase().starts_with(&tl)) {- Some(k) => Err(EngineError::Other(format!("{k} is not available yet."))),- None => Err(invalid_keyword()),- };+ let opts = ["Value", "Position", "Height", "Angle", "Style", "Layer", "Color", "Next"];+ match opts.iter().find(|k| !tl.is_empty() && k.to_ascii_lowercase().starts_with(&tl)) {+ Some(&"Value") => self.stage = Stage::ValueKind,+ Some(&"Position") => self.stage = Stage::Position,+ Some(&"Height") => self.stage = Stage::Height,+ Some(&"Angle") => self.stage = Stage::Angle,+ Some(&"Style") => self.stage = Stage::Style,+ Some(&"Layer") => self.stage = Stage::Layer,+ Some(&"Color") => self.stage = Stage::Color,+ Some(&"Next") => {+ self.cur += 1;+ if self.cur >= self.items.len() {+ return Ok(Step::Done);+ }+ self.show_current(s);+ }+ _ => return Err(invalid_keyword()),+ } } _ => {} },+ Stage::Position => {+ let p = match i {+ Input::Point(p) => p,+ Input::Enter => {+ self.stage = Stage::Option;+ return Ok(Step::Continue);+ }+ _ => return Ok(Step::Continue),+ };+ if let Some(it) = self.items.get(self.cur).copied() {+ modify_attrib(s, it, |a| {+ a.text.insert = Vec3::new(p.x, p.y, a.text.insert.z);+ if a.text.align_pt.is_some() {+ a.text.align_pt = Some(Vec3::new(p.x, p.y, a.text.insert.z));+ }+ })?;+ }+ self.stage = Stage::Option;+ }+ Stage::Height => {+ let h = match &i {+ Input::Point(p) => {+ let cur_pt = self.items.get(self.cur).and_then(|it| current_text(s, *it)).map(|t| t.insert.xy());+ match cur_pt {+ Some(base) => base.dist(*p).max(1e-9),+ None => p.len().max(1e-9),+ }+ }+ Input::Text(t) if !t.trim().is_empty() => crate::units::parse_distance(t.trim())+ .filter(|v| *v > 0.0)+ .ok_or_else(|| EngineError::Other("Requires a positive number.".into()))?,+ Input::Enter => {+ self.stage = Stage::Option;+ return Ok(Step::Continue);+ }+ _ => return Ok(Step::Continue),+ };+ if let Some(it) = self.items.get(self.cur).copied() {+ modify_attrib(s, it, |a| {+ a.text.height = h;+ })?;+ }+ self.stage = Stage::Option;+ }+ Stage::Angle => {+ let ang = match &i {+ Input::Point(p) => {+ let cur_pt = self.items.get(self.cur).and_then(|it| current_text(s, *it)).map(|t| t.insert.xy());+ match cur_pt {+ Some(base) => base.angle_to(*p),+ None => p.y.atan2(p.x),+ }+ }+ Input::Text(t) if !t.trim().is_empty() => {+ s.angle_settings().direction(t.trim()).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?+ }+ Input::Enter => {+ self.stage = Stage::Option;+ return Ok(Step::Continue);+ }+ _ => return Ok(Step::Continue),+ };+ if let Some(it) = self.items.get(self.cur).copied() {+ modify_attrib(s, it, |a| {+ a.text.rotation = ang;+ })?;+ }+ self.stage = Stage::Option;+ }+ Stage::Style => {+ let Some(t) = text else { return Ok(Step::Continue) };+ let t = t.trim();+ if !t.is_empty() {+ let st =+ s.doc()?.text_style(t).map(|st| st.name.clone()).ok_or_else(|| EngineError::Other(format!("Cannot find style \"{t}\".")))?;+ if let Some(it) = self.items.get(self.cur).copied() {+ modify_attrib(s, it, |a| {+ a.text.style = st;+ })?;+ }+ }+ self.stage = Stage::Option;+ } Stage::ValueKind => match &i { Input::Keyword(k) if k == "Change" => self.stage = Stage::Find, Input::Keyword(_) | Input::Enter => self.stage = Stage::NewValue,@@ -79,17 +79,27 @@ pub(crate) fn paste(s: &mut Session, m: &Mat3) -> Result<Vec<Handle>> { let defs = s.clipboard_source.as_ref().map(|c| c.defs.clone()); let space = s.space(); let d = s.doc_mut()?;- let renames = defs.map(|x| merge(d, &x)).unwrap_or_default();+ let renames = defs.as_ref().map(|x| merge(d, x)).unwrap_or_default(); let mut out = Vec::new();+ let mut copies = Vec::new(); for e in &ents {+ let old_h = e.handle; let mut e = adopt(d, e, &renames); e.kind.transform(m); d.ensure_layer(&e.common.layer); out.push(e.handle);+ copies.push((old_h, e.handle)); if let Some(st) = d.space_mut(&space) { st.push(e); } }+ if let Some(c) = &defs+ && !c.groups.is_empty()+ {+ super::groups::replicate_groups_from(d, &c.groups, &copies);+ } else {+ super::groups::replicate_groups_for_copies(d, &copies);+ } s.set_selection(out.clone()); Ok(out) }@@ -272,6 +282,8 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing { d.mleader_styles = pick(&src.mleader_styles, &used.mleader_styles, |t| &t.name); d.table_styles = pick(&src.table_styles, &used.table_styles, |t| &t.name); d.blocks = src.blocks.iter().filter(|(k, _)| used.blocks.contains(&up(k))).map(|(k, b)| (k.clone(), b.clone())).collect();+ let ent_handles: HashSet<Handle> = ents.iter().map(|e| e.handle).collect();+ d.groups = src.groups.iter().filter(|g| !g.members.is_empty() && g.members.iter().all(|m| ent_handles.contains(m))).cloned().collect(); d.layouts.clear(); d }@@ -62,19 +62,11 @@ enum Order { Under, } -/// Move `hs` in the current space's draw order, keeping their order among themselves. Above and-/// Under place them right over the topmost reference object or right under the lowest one.-/// Returns how many objects moved.-fn reorder(s: &mut Session, hs: &[Handle], order: Order, refs: &[Handle]) -> Result<usize> {- let space = s.space();- let d = s.doc_mut()?;- let Some(st) = d.space_mut(&space) else { return Ok(0) };+fn reorder_store(st: &mut cadcraft_doc::EntityStore, hs: &[Handle], order: Order) -> usize { let index: HashMap<Handle, usize> = st.handles().into_iter().enumerate().map(|(i, h)| (h, i)).collect(); let mut moving: Vec<(usize, Handle)> = hs.iter().filter_map(|h| index.get(h).map(|i| (*i, *h))).collect(); moving.sort_unstable(); moving.dedup();- let set: HashSet<Handle> = moving.iter().map(|m| m.1).collect();- let refs: Vec<(usize, Handle)> = refs.iter().filter(|h| !set.contains(h)).filter_map(|h| index.get(h).map(|i| (*i, *h))).collect(); match order { Order::Front => { for (_, h) in &moving {@@ -86,6 +78,29 @@ fn reorder(s: &mut Session, hs: &[Handle], order: Order, refs: &[Handle]) -> Res st.send_to_back(*h); } }+ Order::Above | Order::Under => {}+ }+ moving.len()+}++/// Move `hs` in the current space's draw order, keeping their order among themselves. Above and+/// Under place them right over the topmost reference object or right under the lowest one.+/// Returns how many objects moved.+fn reorder(s: &mut Session, hs: &[Handle], order: Order, refs: &[Handle]) -> Result<usize> {+ let space = s.space();+ let d = s.doc_mut()?;+ let Some(st) = d.space_mut(&space) else { return Ok(0) };+ if matches!(order, Order::Front | Order::Back) {+ return Ok(reorder_store(st, hs, order));+ }+ let index: HashMap<Handle, usize> = st.handles().into_iter().enumerate().map(|(i, h)| (h, i)).collect();+ let mut moving: Vec<(usize, Handle)> = hs.iter().filter_map(|h| index.get(h).map(|i| (*i, *h))).collect();+ moving.sort_unstable();+ moving.dedup();+ let set: HashSet<Handle> = moving.iter().map(|m| m.1).collect();+ let refs: Vec<(usize, Handle)> = refs.iter().filter(|h| !set.contains(h)).filter_map(|h| index.get(h).map(|i| (*i, *h))).collect();+ match order {+ Order::Front | Order::Back => {} Order::Above => { let Some(&(_, mut cur)) = refs.iter().max() else { return Err(EngineError::Other("no reference objects".into())) }; for (_, h) in &moving {@@ -180,15 +195,23 @@ impl Annot { } } -/// Every object of the current space that `keep` accepts, in draw order.-fn of_kind(s: &Session, keep: impl Fn(&EntityKind) -> bool) -> Result<Vec<Handle>> {- let d = s.doc()?;- Ok(d.space(&s.space()).map(|st| st.iter().filter(|e| keep(&e.kind)).map(|e| e.handle).collect()).unwrap_or_default())+/// Reorder objects across all spaces (model space and all paper space layouts).+fn reorder_all_spaces(s: &mut Session, order: Order, keep: impl Fn(&EntityKind) -> bool) -> Result<usize> {+ let d = s.doc_mut()?;+ let mut total = 0;+ // Model space+ let hs: Vec<Handle> = d.model.iter().filter(|e| keep(&e.kind)).map(|e| e.handle).collect();+ total += reorder_store(&mut d.model, &hs, order);+ // All paper space layouts+ for layout in &mut d.layouts {+ let hs: Vec<Handle> = layout.entities.iter().filter(|e| keep(&e.kind)).map(|e| e.handle).collect();+ total += reorder_store(&mut layout.entities, &hs, order);+ }+ Ok(total) } fn annotations_to_front(s: &mut Session, which: Annot) -> Result<Value> {- let hs = of_kind(s, |k| which.matches(k))?;- let n = reorder(s, &hs, Order::Front, &[])?;+ let n = reorder_all_spaces(s, Order::Front, |k| which.matches(k))?; Ok(json!({ "moved": n, "message": format!("{n} object(s) brought to front.") })) } @@ -207,8 +230,7 @@ fn run_texttofront(s: &mut Session, p: &Value) -> Result<Value> { } fn run_hatchtoback(s: &mut Session, _p: &Value) -> Result<Value> {- let hs = of_kind(s, |k| matches!(k, EntityKind::Hatch(_)))?;- let n = reorder(s, &hs, Order::Back, &[])?;+ let n = reorder_all_spaces(s, Order::Back, |k| matches!(k, EntityKind::Hatch(_)))?; Ok(json!({ "moved": n, "message": format!("{n} hatch object(s) sent to back.") })) } @@ -70,6 +70,35 @@ fn next_unnamed(d: &Drawing) -> String { (1..=d.groups.len() + 1).map(|n| format!("*A{n}")).find(|n| !used.contains(n)).unwrap_or_else(|| format!("*A{}", d.groups.len() + 1)) } +/// When entities are copied, replicate any whole groups whose members were all copied.+pub(crate) fn replicate_groups_from(d: &mut Drawing, src_groups: &[Group], copies: &[(Handle, Handle)]) {+ if copies.is_empty() || src_groups.is_empty() {+ return;+ }+ let map: std::collections::HashMap<Handle, Handle> = copies.iter().copied().collect();+ let mut new_groups = Vec::new();+ for g in src_groups {+ let active_members: Vec<Handle> = g.members.iter().copied().filter(|h| d.entity(*h).is_some()).collect();+ if !active_members.is_empty() && active_members.iter().all(|h| map.contains_key(h)) {+ let new_members: Vec<Handle> = active_members.iter().filter_map(|h| map.get(h).copied()).collect();+ new_groups.push((g.description.clone(), g.selectable, new_members));+ }+ }+ for (desc, selectable, members) in new_groups {+ if d.groups.len() >= MAX_GROUPS {+ break;+ }+ let name = next_unnamed(d);+ d.groups.push(Group { name, description: desc, selectable, members });+ }+}++/// When entities in the drawing `d` are copied, replicate any whole groups among them.+pub(crate) fn replicate_groups_for_copies(d: &mut Drawing, copies: &[(Handle, Handle)]) {+ let groups = d.groups.clone();+ replicate_groups_from(d, &groups, copies);+}+ /// `hs` without repeats and without handles of objects that don't exist. fn existing(d: &Drawing, hs: &[Handle]) -> Vec<Handle> { let mut seen = std::collections::HashSet::new();@@ -108,6 +108,12 @@ enum Stage { Transparency, Plot, Description,+ State,+ StateSave,+ StateRestore,+ StateDelete,+ StateRenameFrom,+ StateRenameTo(String), Layers(Change), /// Turning the current layer off asks first. ConfirmOff(Vec<String>),@@ -188,8 +194,9 @@ impl LayerM { "LOck" => Stage::Layers(Change::Lock), "Unlock" => Stage::Layers(Change::Unlock), "Description" => Stage::Description,+ "stAte" => Stage::State, other => {- // MATerial, stAte, rEconcile: report and stay at the option prompt.+ // MATerial, rEconcile: report and stay at the option prompt. s.echo(format!("-LAYER {other} is not available yet.")); Stage::Option }@@ -390,6 +397,72 @@ impl LayerM { } }, Stage::Description => back(self, Stage::Layers(Change::Description(text.unwrap_or_default()))),+ Stage::State => match &i {+ Input::Keyword(k) => match k.as_str() {+ "?" => {+ let names: Vec<String> = s.doc()?.layer_states.iter().map(|st| st.name.clone()).collect();+ s.echo(if names.is_empty() { "No saved layer states.".to_string() } else { format!("Layer states: {}", names.join(", ")) });+ back(self, Stage::State)+ }+ "Save" => back(self, Stage::StateSave),+ "Restore" => back(self, Stage::StateRestore),+ "Delete" => back(self, Stage::StateDelete),+ "Name" | "Rename" => back(self, Stage::StateRenameFrom),+ "Edit" => {+ s.echo("Layer state editing is not available yet.");+ back(self, Stage::State)+ }+ _ => Ok(Step::Continue),+ },+ Input::Enter => Ok(Step::Continue),+ _ => {+ self.stage = Stage::State;+ Err(retry("Invalid option keyword."))+ }+ },+ Stage::StateSave => {+ if let Some(n) = text+ && let Err(e) = call(s, "layerstate.save", &json!({ "name": n }))+ {+ s.echo(message(&e));+ }+ Ok(Step::Continue)+ }+ Stage::StateRestore => {+ if let Some(n) = text+ && let Err(e) = call(s, "layerstate.restore", &json!({ "name": n }))+ {+ s.echo(message(&e));+ }+ Ok(Step::Continue)+ }+ Stage::StateDelete => {+ if let Some(n) = text+ && let Err(e) = call(s, "layerstate.delete", &json!({ "name": n }))+ {+ s.echo(message(&e));+ }+ Ok(Step::Continue)+ }+ Stage::StateRenameFrom => match text {+ None => Ok(Step::Continue),+ Some(n) => {+ if s.doc()?.layer_states.iter().any(|st| st.name == n) {+ back(self, Stage::StateRenameTo(n))+ } else {+ s.echo(format!("No layer state named \"{n}\"."));+ Ok(Step::Continue)+ }+ }+ },+ Stage::StateRenameTo(old) => {+ if let Some(to) = text+ && let Err(e) = call(s, "layerstate.rename", &json!({ "from": old, "to": to }))+ {+ s.echo(message(&e));+ }+ Ok(Step::Continue)+ } Stage::Layers(change) => self.layers(s, change, text.as_deref()), Stage::ConfirmOff(names) => { let cur = current_layer(s);@@ -428,6 +501,12 @@ impl Interactive for LayerM { Stage::Transparency => Prompt::new("Enter transparency value (0-90)", NAME).default("0"), Stage::Plot => Prompt::new("Enter a plotting preference", KW).kw(&["Plot", "No"]).default("Plot"), Stage::Description => Prompt::new("Enter layer description", Accept::TEXT),+ Stage::State => Prompt::new("Enter an option", KW).kw(&["?", "Save", "Restore", "Edit", "Name", "Delete"]),+ Stage::StateSave => Prompt::new("Enter name of layer state to save", NAME),+ Stage::StateRestore => Prompt::new("Enter name of layer state to restore", NAME),+ Stage::StateDelete => Prompt::new("Enter name of layer state to delete", NAME),+ Stage::StateRenameFrom => Prompt::new("Enter old layer state name", NAME),+ Stage::StateRenameTo(_) => Prompt::new("Enter new layer state name", NAME), Stage::Layers(c) => { let (msg, enter_is_current) = c.prompt(); let p = Prompt::new(msg, NAME);@@ -102,6 +102,7 @@ pub fn specs() -> Vec<CommandSpec> { pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> { let d = s.doc_mut()?; let mut out = Vec::new();+ let mut copies = Vec::new(); for h in hs { let Some(sp) = d.space_of(*h) else { continue }; if copy {@@ -112,6 +113,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: if let Some(st) = d.space_mut(&sp) { st.push(ne); out.push(nh);+ copies.push((*h, nh)); } } else { let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked);@@ -122,6 +124,9 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: out.push(*h); } }+ if copy && !copies.is_empty() {+ super::groups::replicate_groups_for_copies(d, &copies);+ } Ok(out) } @@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> { CommandSpec::new("rename", "Rename...", run_rename) .menu(&["Format", "Rename..."]) .alias(&["ren", "-rename"])- .params("{table: layer|linetype|style|dimstyle|block|mleaderstyle|tablestyle|view|ucs, from, to}")+ .params("{table: layer|linetype|style|dimstyle|block|mleaderstyle|tablestyle|view|ucs|vport, from, to}") .interactive(|_| Ok(Box::new(rename::RenameM::default()))), ] }@@ -1125,7 +1125,7 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> { } "block" => rename::block(s, &from, &to)?, "mleaderstyle" | "multileaderstyle" => rename::simple(s, "mleaderstyle", &from, &to)?,- "tablestyle" | "view" | "ucs" => rename::simple(s, &table, &from, &to)?,+ "tablestyle" | "view" | "ucs" | "vport" => rename::simple(s, &table, &from, &to)?, "style" | "textstyle" => { run_style_rename(s, &json!({ "from": from, "to": to }))?; }@@ -87,12 +87,16 @@ pub(super) fn simple(s: &mut Session, table: &str, from: &str, to: &str) -> Resu if matches!(table, "mleaderstyle" | "tablestyle") && from.eq_ignore_ascii_case("Standard") { return Err(bad("rename", "the Standard style cannot be renamed")); }+ if table == "vport" && from.eq_ignore_ascii_case(cadcraft_doc::VportConfig::ACTIVE) {+ return Err(bad("rename", "the active viewport configuration cannot be renamed"));+ } let d = s.doc_mut()?; let mut names: Vec<&mut String> = match table { "mleaderstyle" => d.mleader_styles.iter_mut().map(|t| &mut t.name).collect(), "tablestyle" => d.table_styles.iter_mut().map(|t| &mut t.name).collect(), "view" => d.views.iter_mut().map(|t| &mut t.name).collect(), "ucs" => d.ucss.iter_mut().map(|t| &mut t.name).collect(),+ "vport" => d.vports.iter_mut().filter(|c| !c.is_active()).map(|t| &mut t.name).collect(), _ => return Err(bad("rename", "unsupported table")), }; if !to.eq_ignore_ascii_case(from) && names.iter().any(|n| n.eq_ignore_ascii_case(&to)) {@@ -136,7 +140,7 @@ const TYPES: &[(&str, Option<&str>, &str)] = &[ ("Tablestyle", Some("tablestyle"), "table style"), ("Ucs", Some("ucs"), "UCS"), ("VIew", Some("view"), "view"),- ("VPort", None, "viewport configuration"),+ ("VPort", Some("vport"), "viewport configuration"), ]; /// Keyword-only: spaces separate inputs.@@ -159,6 +163,7 @@ fn exists(d: &Drawing, table: &str, name: &str) -> bool { "tablestyle" => any_named(d.table_styles.iter().map(|t| &t.name), name), "view" => any_named(d.views.iter().map(|t| &t.name), name), "ucs" => any_named(d.ucss.iter().map(|t| &t.name), name),+ "vport" => any_named(d.vports.iter().filter(|c| !c.is_active()).map(|c| &c.name), name), _ => true, } }@@ -414,6 +414,9 @@ pub struct Settings { /// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches /// with their boundaries (stored, not used yet). pub pickstyle: u8,+ /// GROUPDISPLAYMODE: how groups are displayed when selected:+ /// 0 individual object grips only, 1 single center grip and bounding box, 2 both (default).+ pub groupdisplaymode: u8, pub gripsize: f64, pub cursorsize: f64, /// MAXARRAY: the most objects one array command creates (items × selected objects).@@ -504,6 +507,7 @@ impl Default for Settings { pickadd: true, pickdrag: true, pickstyle: 1,+ groupdisplaymode: 2, gripsize: 5.0, cursorsize: 5.0, maxarray: 100_000,@@ -60,6 +60,7 @@ const SESSION_VARS: &[&str] = &[ "PICKADD", "PICKDRAG", "PICKSTYLE",+ "GROUPDISPLAYMODE", "GRIPSIZE", "CURSORSIZE", "MAXARRAY",@@ -137,6 +138,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> { "PICKADD" => json!(i32::from(st.pickadd)), "PICKDRAG" => json!(i32::from(st.pickdrag) * 2), "PICKSTYLE" => json!(st.pickstyle),+ "GROUPDISPLAYMODE" => json!(st.groupdisplaymode), "GRIPSIZE" => json!(st.gripsize), "CURSORSIZE" => json!(st.cursorsize), "MAXARRAY" => json!(st.maxarray),@@ -355,6 +357,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?, "PICKDRAG" => st.pickdrag = as_i(v).filter(|b| matches!(b, 0 | 2)).map(|b| b == 2).ok_or_else(bad)?, "PICKSTYLE" => st.pickstyle = as_i(v).filter(|b| (0..=3).contains(b)).and_then(|b| u8::try_from(b).ok()).ok_or_else(bad)?,+ "GROUPDISPLAYMODE" => st.groupdisplaymode = small(v, 2).ok_or_else(bad)?, "GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0), "CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0), "MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64,@@ -83,14 +83,22 @@ fn dash_attedit_one_at_a_time_edits_the_selected_attributes() { let mut s = with_block(); let h = handle(&insert(&mut s, 100.0, json!({"T": "abc"}))); type_lines(&mut s, &["-attedit", "y", "", "T", "", "105,0", ""]);- assert!(s.prompt_text().contains("[Value/Layer/Color/Next] <N>"), "{}", s.prompt_text());- // Unsupported options say so and keep the prompt.- s.cmdline("p").unwrap();- assert!(s.log.iter().any(|l| l.contains("Position is not available yet")), "{:?}", s.log);+ assert!(s.prompt_text().contains("[Value/Position/Height/Angle/Style/Layer/Color/Next] <N>"), "{}", s.prompt_text());+ // Edit Position, Height, Angle, and Style+ type_lines(&mut s, &["p", "10,20", "h", "2.5", "a", "45", "s", "Standard"]);+ // Edit Value: change and replace type_lines(&mut s, &["v", "c", "b", "B", "v", "r", "replaced value", "n"]); assert!(s.running.is_none(), "{}", s.prompt_text()); assert_eq!(value(&s, h, "T"), "replaced value"); assert_eq!(value(&s, h, "U"), "u");++ let EntityKind::Insert(ins) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") };+ let att = ins.attribs.iter().find(|a| a.tag == "T").unwrap();+ assert!((att.text.insert.x - 10.0).abs() < 1e-6);+ assert!((att.text.insert.y - 20.0).abs() < 1e-6);+ assert!((att.text.height - 2.5).abs() < 1e-6);+ assert!((att.text.rotation.to_degrees() - 45.0).abs() < 1e-4);+ assert_eq!(att.text.style, "Standard"); } #[test]@@ -38,7 +38,7 @@ fn dash_attedit_sets_layer_and_colour_of_one_attribute() { for t in ["-attedit", "y", "", "T", "", "105,0", ""] { s.cmdline(t).unwrap(); }- assert!(s.prompt_text().contains("[Value/Layer/Color/Next] <N>"), "{}", s.prompt_text());+ assert!(s.prompt_text().contains("[Value/Position/Height/Angle/Style/Layer/Color/Next] <N>"), "{}", s.prompt_text()); s.cmdline("l").unwrap(); assert!(s.prompt_text().contains("Enter new layer name <0>"), "{}", s.prompt_text()); // An unknown layer is refused and asked again.@@ -145,3 +145,53 @@ fn texttofront_and_hatchtoback() { s.cmdline("").unwrap(); assert!(s.log.iter().any(|l| l.contains("2 object(s) brought to front")), "{:?}", s.log); }++#[test]+fn texttofront_and_hatchtoback_across_all_spaces() {+ let mut s = Session::new();+ // In Model space: 1 line, 1 hatch, 1 text.+ s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();+ s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();+ s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap();+ s.execute("text", &json!({ "at": [0, 0], "height": 2, "text": "MText1" })).unwrap();++ // Create a paper space layout and switch to it.+ s.execute("layout.new", &json!({ "name": "Sheet1", "viewport": false })).unwrap();+ s.execute("layout.set", &json!({ "name": "Sheet1" })).unwrap();++ // In Sheet1 paper space: 1 line, 1 hatch, 1 text.+ s.execute("line", &json!({ "points": [[0, 0], [20, 0]] })).unwrap();+ s.execute("rectang", &json!({ "p1": [0, 0], "p2": [20, 20] })).unwrap();+ s.execute("hatch", &json!({ "points": [[10, 10]], "pattern": "SOLID" })).unwrap();+ s.execute("text", &json!({ "at": [0, 0], "height": 3, "text": "PText1" })).unwrap();++ // Move hatches to the front in both spaces first.+ let m_hatch = s.doc().unwrap().model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).unwrap().handle;+ let p_layout = s.doc().unwrap().layouts.iter().find(|l| l.name == "Sheet1").unwrap();+ let p_hatch = p_layout.entities.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).unwrap().handle;++ s.execute("layout.set", &json!({ "name": "Model" })).unwrap();+ s.execute("draworder.front", &json!({ "handles": [m_hatch.hex()] })).unwrap();+ s.execute("layout.set", &json!({ "name": "Sheet1" })).unwrap();+ s.execute("draworder.front", &json!({ "handles": [p_hatch.hex()] })).unwrap();++ // HATCHTOBACK acts across ALL spaces: model space AND layouts.+ let r = s.execute("hatchtoback", &json!({})).unwrap();+ assert_eq!(r["moved"], 2, "hatches sent to back in both model space and paper layout");++ let doc = s.doc().unwrap();+ let m_first = doc.model.iter().next().unwrap();+ assert!(matches!(m_first.kind, EntityKind::Hatch(_)), "model space hatch is back");+ let l_first = doc.layouts.iter().find(|l| l.name == "Sheet1").unwrap().entities.iter().next().unwrap();+ assert!(matches!(l_first.kind, EntityKind::Hatch(_)), "paper space hatch is back");++ // TEXTTOFRONT acts across ALL spaces: brings text in both model space and paper layout to front.+ let r = s.execute("texttofront", &json!({ "objects": "text" })).unwrap();+ assert_eq!(r["moved"], 2, "texts brought to front in both model space and paper layout");++ let doc = s.doc().unwrap();+ let m_last = doc.model.iter().last().unwrap();+ assert!(matches!(m_last.kind, EntityKind::Text(_)), "model space text is front");+ let l_last = doc.layouts.iter().find(|l| l.name == "Sheet1").unwrap().entities.iter().last().unwrap();+ assert!(matches!(l_last.kind, EntityKind::Text(_)), "paper space text is front");+}@@ -155,3 +155,50 @@ fn erased_members_leave_groups() { s.cmdline("").unwrap(); assert_eq!(groups(&s).iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["B"]); }++#[test]+fn copy_whole_group_replicates_group() {+ let (mut s, hs) = three_lines();+ s.execute("group", &json!({ "name": "Pair", "description": "two lines", "handles": hex(&hs[..2]) })).unwrap();+ assert_eq!(s.doc().unwrap().groups.len(), 1);++ // Copying both members with COPY creates a new unnamed group *A1.+ let r = s.execute("copy", &json!({ "handles": hex(&hs[..2]), "delta": [0, 20] })).unwrap();+ let copied: Vec<String> = r["handles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();+ assert_eq!(copied.len(), 2);+ let gs = groups(&s);+ assert_eq!(gs.len(), 2, "a new group was created for the copied pair");+ assert_eq!(gs[1].0, "*A1");+ assert_eq!(gs[1].1, "two lines");+ assert_eq!(hex(&gs[1].2), copied);++ // Copying only ONE member of the group does not create another group.+ s.execute("copy", &json!({ "handles": [hs[0].hex()], "delta": [0, 40] })).unwrap();+ assert_eq!(s.doc().unwrap().groups.len(), 2, "partial copy does not replicate group");++ // Copying to clipboard and pasting also replicates the whole group.+ s.set_selection(vec![hs[0], hs[1]]);+ s.execute("copyclip", &json!({})).unwrap();+ let r = s.execute("pasteclip", &json!({ "at": [50, 50] })).unwrap();+ let pasted: Vec<String> = r["handles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();+ let gs = groups(&s);+ assert_eq!(gs.len(), 3, "pasting whole group replicates it");+ assert_eq!(gs[2].0, "*A2");+ assert_eq!(hex(&gs[2].2), pasted);+}++#[test]+fn groupdisplaymode_sysvar() {+ let mut s = Session::new();+ let v = s.execute("getvar", &json!({ "name": "GROUPDISPLAYMODE" })).unwrap();+ assert_eq!(v["value"], 2, "GROUPDISPLAYMODE defaults to 2");++ s.execute("setvar", &json!({ "name": "GROUPDISPLAYMODE", "value": 0 })).unwrap();+ assert_eq!(s.settings.groupdisplaymode, 0);++ s.execute("setvar", &json!({ "name": "GROUPDISPLAYMODE", "value": 1 })).unwrap();+ assert_eq!(s.settings.groupdisplaymode, 1);++ assert!(s.execute("setvar", &json!({ "name": "GROUPDISPLAYMODE", "value": 3 })).is_err());+ assert!(s.execute("setvar", &json!({ "name": "GROUPDISPLAYMODE", "value": -1 })).is_err());+}@@ -71,3 +71,29 @@ fn layer_commands_reject_unloaded_linetypes_and_duplicate_renames() { s.execute("layer.set", &json!({ "name": "A", "newName": "a", "linetype": "continuous" })).unwrap(); assert_eq!(s.doc().unwrap().layer("a").unwrap().linetype, "Continuous"); }++#[test]+fn layer_cmdline_state_save_restore_rename_delete() {+ let mut s = Session::new();+ s.execute("layer.new", &json!({ "name": "L1", "color": "1" })).unwrap();+ // Test -LAYER A (stAte) Save+ s.script("-LAYER A S St1 \n\n").unwrap();+ assert!(s.doc().unwrap().layer_states.iter().any(|st| st.name == "St1"));++ // Modify layer L1 color+ s.execute("layer.set", &json!({ "name": "L1", "color": "2" })).unwrap();+ assert_eq!(s.doc().unwrap().layer("L1").unwrap().color, cadcraft_color::Color::Index(2));++ // Restore St1+ s.script("-LAYER A R St1 \n\n").unwrap();+ assert_eq!(s.doc().unwrap().layer("L1").unwrap().color, cadcraft_color::Color::Index(1));++ // Rename St1 -> St2 (Name)+ s.script("-LAYER A N St1 St2 \n\n").unwrap();+ assert!(s.doc().unwrap().layer_states.iter().any(|st| st.name == "St2"));+ assert!(!s.doc().unwrap().layer_states.iter().any(|st| st.name == "St1"));++ // Delete St2+ s.script("-LAYER A D St2 \n\n").unwrap();+ assert!(!s.doc().unwrap().layer_states.iter().any(|st| st.name == "St2"));+}@@ -81,8 +81,19 @@ fn rename_prompts_type_old_and_new_name() { assert!(s.running.is_none(), "{}", s.prompt_text()); assert!(s.doc().unwrap().layer("Newer").is_some()); let n = s.log.len();- s.script("-RENAME VP a b\nLINE 0,0 2,0\n\n").unwrap();+ s.script("-RENAME M a b\nLINE 0,0 2,0\n\n").unwrap(); assert!(s.running.is_none(), "{}", s.prompt_text()); assert!(s.log[n..].iter().any(|l| l.contains("not available yet")), "{:?}", &s.log[n..]); assert_eq!(s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(), 2);++ // VPort configuration rename+ s.doc_mut().unwrap().vports.push(cadcraft_doc::VportConfig { name: "MyVP".into(), tiles: vec![] });+ s.script("-RENAME VP MyVP NewVP\n").unwrap();+ assert!(s.running.is_none(), "{}", s.prompt_text());+ let d = s.doc().unwrap();+ assert!(d.vports.iter().any(|v| v.name == "NewVP"));+ assert!(!d.vports.iter().any(|v| v.name == "MyVP"));++ // Cannot rename *Active viewport configuration+ assert!(s.execute("rename", &json!({ "table": "vport", "from": "*Active", "to": "Active2" })).is_err()); }@@ -1943,7 +1943,47 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { && let Ok(st) = app.session.state() { let gs = app.session.settings.gripsize as f32;+ let gdm = app.session.settings.groupdisplaymode;+ let mut grouped_members = std::collections::HashSet::new();+ if gdm > 0 && app.session.settings.pickstyle & 1 != 0 {+ for g in &st.doc.groups {+ if g.selectable && !g.members.is_empty() && g.members.iter().all(|h| sel.contains(h)) {+ let mut b = cadcraft_geom::Bounds2::EMPTY;+ for h in &g.members {+ if let Some(e) = st.doc.entity(*h) {+ grouped_members.insert(*h);+ let eb = cadcraft_engine::doc::entity_bounds(&st.doc, e, 0);+ b = b.union(&eb);+ }+ }+ if !b.is_empty() {+ let bounds = b;+ let min_sp = xf.to_screen(bounds.min);+ let max_sp = xf.to_screen(bounds.max);+ let box_rect = Rect::from_two_pos(min_sp, max_sp).expand(4.0);+ if rect.intersects(box_rect) {+ painter.rect_stroke(+ box_rect,+ 0.0,+ Stroke::new(1.0, Color32::from_rgba_unmultiplied(0x80, 0x90, 0xb0, 0xb0)),+ egui::StrokeKind::Middle,+ );+ let center_sp = xf.to_screen(bounds.center());+ if rect.contains(center_sp) {+ let cr = Rect::from_center_size(center_sp, egui::vec2(gs * 2.0, gs * 2.0));+ let warm = hover_pos.is_some_and(|hp| cr.expand(2.0).contains(hp));+ painter.rect_filled(cr, 0.0, if warm { Color32::from_rgb(0xff, 0x7f, 0x9f) } else { t.grip });+ painter.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_rgb(0x10, 0x30, 0x80)), egui::StrokeKind::Middle);+ }+ }+ }+ }+ }+ } for h in sel.iter().take(200) {+ if gdm == 1 && grouped_members.contains(h) {+ continue;+ } if let Some(e) = st.doc.entity(*h) { let grips = e.kind.grips(); let count = grips.len();@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (GROUPDISPLAYMODE and group replication in F16; TEXTTOFRONT/HATCHTOBACK across layouts in F19) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -24,7 +24,7 @@ do them first. | # | Gap | Evidence | User impact | Est. | |---|---|---|---|---|-| F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 |+| F1 | **[alpha blocker]** Prompt options that still print "not available yet": colour books in -ATTEDIT Color, -RENAME of materials and plot styles. -ATTEDIT one-at-a-time Position, Height, Angle and Style done; -RENAME of viewport configurations (VPort) done. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–2 | | F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 |@@ -37,10 +37,10 @@ do them first. | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | | F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 |-| F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 |-| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |+| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/Restore/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 1–2 |+| F16 | Groups: group bounding box and center grip when a group is selected (GROUPDISPLAYMODE, 0/1/2) and copying a whole group replicates it into a new unnamed group done. Left: no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 1–2 | | F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |-| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |+| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT and HATCHTOBACK across model space and all paper space layouts, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 1–2 | | F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | | F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | | F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 |